From d0f6f9d9f6130b91986c3f2d03f60501cf848f36 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 5 Mar 2024 22:09:52 +0600 Subject: [PATCH 001/261] chore: add test macos cask definition --- Casks/spotube.rb | 22 ++++++++++++++++++++++ linux/spotube.desktop | 1 + 2 files changed, 23 insertions(+) create mode 100644 Casks/spotube.rb diff --git a/Casks/spotube.rb b/Casks/spotube.rb new file mode 100644 index 00000000..32a92802 --- /dev/null +++ b/Casks/spotube.rb @@ -0,0 +1,22 @@ +cask "spotube" do + version "3.4.1" + sha256 "5686cb0b1b261399062250c36b7bf9c481e4c36c76615d787e01c77036fe6cba" + + url "https://github.com/KRTirtho/spotube/releases/download/v#{version}/Spotube-macos-universal.dmg", + verified: "github.com/KRTirtho/spotube/" + name "Spotube" + desc "🎧 Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!" + homepage "https://spotube.krtirtho.dev/" + + livecheck do + url :url + strategy :github_latest + end + + app "spotube.app" + + zap trash: [ + "~/Library/Application Scripts/oss.krtirtho.spotube", + "~/Library/Containers/oss.krtirtho.spotube", + ] +end \ No newline at end of file diff --git a/linux/spotube.desktop b/linux/spotube.desktop index 0bda851f..53f381e1 100644 --- a/linux/spotube.desktop +++ b/linux/spotube.desktop @@ -6,3 +6,4 @@ Icon=/usr/share/icons/spotube/spotube-logo.png Comment=A music streaming app combining the power of Spotify & YouTube Terminal=false Categories=Audio;Music;Player;AudioVideo; +MimeType=x-scheme-handler/spotify; \ No newline at end of file From d1f0e778f6998cfcf182dc2a607472ab0e17b8b3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 5 Mar 2024 22:17:56 +0600 Subject: [PATCH 002/261] chore: remove casks --- Casks/spotube.rb | 22 ---------------------- 1 file changed, 22 deletions(-) delete mode 100644 Casks/spotube.rb diff --git a/Casks/spotube.rb b/Casks/spotube.rb deleted file mode 100644 index 32a92802..00000000 --- a/Casks/spotube.rb +++ /dev/null @@ -1,22 +0,0 @@ -cask "spotube" do - version "3.4.1" - sha256 "5686cb0b1b261399062250c36b7bf9c481e4c36c76615d787e01c77036fe6cba" - - url "https://github.com/KRTirtho/spotube/releases/download/v#{version}/Spotube-macos-universal.dmg", - verified: "github.com/KRTirtho/spotube/" - name "Spotube" - desc "🎧 Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!" - homepage "https://spotube.krtirtho.dev/" - - livecheck do - url :url - strategy :github_latest - end - - app "spotube.app" - - zap trash: [ - "~/Library/Application Scripts/oss.krtirtho.spotube", - "~/Library/Containers/oss.krtirtho.spotube", - ] -end \ No newline at end of file From bda76a59ecc59be79b858de0cc0399b98ffd8332 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 5 Mar 2024 22:45:06 +0600 Subject: [PATCH 003/261] docs: add installation through homebrew instruction in README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 18eb55aa..b8f0c9b8 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,15 @@ This handy table lists all the methods you can use to install Spotube: + + Macos - Homebrew + +
+brew tap krtirtho/apps
+brew install --cask spotube
+
+ + Windows - Chocolatey From ca76a39910b1a5af91aa7882a0d33c9d71db58a2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 8 Mar 2024 19:10:33 +0600 Subject: [PATCH 004/261] fix: album images are small in certain places --- lib/components/album/album_card.dart | 4 ++-- lib/pages/album/album.dart | 1 - lib/utils/type_conversion_utils.dart | 9 +++++++-- untranslated_messages.json | 30 ++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 5 deletions(-) diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index c7ae2f9a..4d2e12d6 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -21,8 +21,8 @@ class AlbumCard extends HookConsumerWidget { final AlbumSimple album; const AlbumCard( this.album, { - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 6cba99f6..4578aea2 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -47,7 +47,6 @@ class AlbumPage extends HookConsumerWidget { image: TypeConversionUtils.image_X_UrlString( album.images, placeholder: ImagePlaceholder.albumArt, - index: 0, ), title: album.name!, description: diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index 662b611c..cd594a2a 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -2,6 +2,7 @@ import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart' hide Image; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; @@ -32,8 +33,12 @@ abstract class TypeConversionUtils { "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", }[placeholder]!; - return images != null && images.isNotEmpty - ? images[index > images.length - 1 ? images.length - 1 : index].url! + final sortedImage = images?.sorted((a, b) => a.width!.compareTo(b.width!)); + + return sortedImage != null && sortedImage.isNotEmpty + ? sortedImage[ + index > sortedImage.length - 1 ? sortedImage.length - 1 : index] + .url! : placeholderUrl; } diff --git a/untranslated_messages.json b/untranslated_messages.json index 14eead0f..a6724f2b 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -299,6 +299,36 @@ "browse_anonymously" ], + "ko": [ + "sort_duration", + "start_a_radio", + "how_to_start_radio", + "replace_queue_question", + "endless_playback", + "delete_playlist", + "delete_playlist_confirmation", + "local_tracks", + "song_link", + "skip_this_nonsense", + "freedom_of_music", + "freedom_of_music_palm", + "get_started", + "youtube_source_description", + "piped_source_description", + "jiosaavn_source_description", + "highest_quality", + "select_audio_source", + "endless_playback_description", + "choose_your_region", + "choose_your_region_description", + "choose_your_language", + "help_project_grow", + "help_project_grow_description", + "contribute_on_github", + "donate_on_open_collective", + "browse_anonymously" + ], + "ne": [ "sort_duration", "start_a_radio", From b354f57d4e6eb0a471ae23cd4e3dcbb9e7e9a0cd Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 8 Mar 2024 19:12:40 +0600 Subject: [PATCH 005/261] chore: hide songlink when not youtube track --- lib/components/player/player.dart | 38 +++++++++++++++---------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 01e38670..458676e3 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -24,6 +24,7 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -138,26 +139,25 @@ class PlayerView extends HookConsumerWidget { onPressed: panelController.close, ), actions: [ - TextButton.icon( - icon: Assets.logos.songlinkTransparent.image( - width: 20, - height: 20, - color: bodyTextColor, - ), - label: Text(context.l10n.song_link), - style: TextButton.styleFrom( - foregroundColor: bodyTextColor, - padding: EdgeInsets.zero, - ), - onPressed: currentTrack == null - ? null - : () { - final url = - "https://song.link/s/${currentTrack.id}"; + if (currentTrack is YoutubeSourcedTrack) + TextButton.icon( + icon: Assets.logos.songlinkTransparent.image( + width: 20, + height: 20, + color: bodyTextColor, + ), + label: Text(context.l10n.song_link), + style: TextButton.styleFrom( + foregroundColor: bodyTextColor, + padding: EdgeInsets.zero, + ), + onPressed: () { + final url = + "https://song.link/s/${currentTrack.id}"; - launchUrlString(url); - }, - ), + launchUrlString(url); + }, + ), IconButton( icon: const Icon(SpotubeIcons.info, size: 18), tooltip: context.l10n.details, From 5019c14c443908f1245c07a84b38adc8b2caf257 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 8 Mar 2024 19:50:57 +0600 Subject: [PATCH 006/261] chore: fix getting started screen theme and bump version and generate changelogs --- CHANGELOG.md | 31 +++++ lib/extensions/string.dart | 6 + .../getting_started/getting_started.dart | 116 ++++++++++-------- .../getting_started/sections/playback.dart | 3 +- .../audio_player/mk_state_player.dart | 30 +++-- pubspec.yaml | 2 +- 6 files changed, 117 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f48b39e..02624a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) + + +### Features + +* add endless playback support [#285](https://github.com/krtirtho/spotube/issues/285) ([9dfd49c](https://github.com/krtirtho/spotube/commit/9dfd49ca04f0e915e333e205b17ac70456873f6e)) +* add getting started page ([96a2a1f](https://github.com/krtirtho/spotube/commit/96a2a1f5a622cb3c580041417d5023e37fa69716)) +* Add iOS background play support ([#1166](https://github.com/krtirtho/spotube/issues/1166)) ([095587e](https://github.com/krtirtho/spotube/commit/095587ee84f7d867c69fcf4b09ed608d63478e1e)) +* add songlink based track matching for youtube and open song link button ([9095a8c](https://github.com/krtirtho/spotube/commit/9095a8c8f849e42daabb7efcc20085cfb863c974)) +* **playlist:** show confirmation before deleting user playlist [#1222](https://github.com/krtirtho/spotube/issues/1222) ([9f92440](https://github.com/krtirtho/spotube/commit/9f9244062a39759aa0ce28d2d5f7c8fa53d73003)) +* Sort by Duration ([#1238](https://github.com/krtirtho/spotube/issues/1238)) ([6f8271f](https://github.com/krtirtho/spotube/commit/6f8271f5e9394cb4053e41dd222aa2844c34d609)) +* start radio support ([4defeef](https://github.com/krtirtho/spotube/commit/4defeefe7e5947aa00a2afb2a06577ec141cdc52)) +* **translations:** add Korean translation ([#1275](https://github.com/krtirtho/spotube/issues/1275)) ([fdea930](https://github.com/krtirtho/spotube/commit/fdea9307bbfb8f3f62cfb795bfb3ca58c38c33d9)) +* **translations:** Added Vietnamese ([#1135](https://github.com/krtirtho/spotube/issues/1135)) ([019ba86](https://github.com/krtirtho/spotube/commit/019ba865e20a8b54ea3490c01e47158eaf3a4c8d)) +* **windows:** Install Visual C++ 2015-2022 Redistributable if missing when installing ([ba69496](https://github.com/krtirtho/spotube/commit/ba69496dcc9a1b7f6ea4e104e71764a854d27f1f)) + + +### Bug Fixes + +* album images are small in certain places ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) +* album, artist page not loading [#1282](https://github.com/krtirtho/spotube/issues/1282) ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) +* **android:** audio issue when screen is off and broadcast audio session id ([#1221](https://github.com/krtirtho/spotube/issues/1221) & [#1247](https://github.com/krtirtho/spotube/issues/1247)) ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571) +* **android:** pressing back button in any other tab other than home exits the app ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) +* **android:** system back button in player page exits the app ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) +* cleanTitle removing feat and ft from words instead of whole words ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) +* friends list not scrollable with mouse drag ([ab08c82](https://github.com/krtirtho/spotube/commit/ab08c82c8dd501263049f3adcbd48907ba13e3a9)) +* no draggable scrollbar in playlist/album page [#1158](https://github.com/krtirtho/spotube/issues/1158) ([6f71e52](https://github.com/krtirtho/spotube/commit/6f71e52ea8a5712d2c3527f2a524af9fbb718bef)) +* non-banger songs breaking the queue if sources not found ([90f7c53](https://github.com/krtirtho/spotube/commit/90f7c531cdc8640afdbabf5a0592159715ea1e6f)) +* track loading when not found in Youtube ([e964f61](https://github.com/krtirtho/spotube/commit/e964f61d38cb303e3d3fd60c866414f57207181c)) +* **translations:** Update app_nl.arb ([#1168](https://github.com/krtirtho/spotube/issues/1168)) ([8167963](https://github.com/krtirtho/spotube/commit/8167963212eeb5dfb0b4fb2eadf81d466659a9f1)) + ## [3.4.1](https://personal.github.com/krtirtho/spotube/compare/v3.4.0...v3.4.1) (2024-01-27) diff --git a/lib/extensions/string.dart b/lib/extensions/string.dart index b7ab7514..0aa41dc6 100644 --- a/lib/extensions/string.dart +++ b/lib/extensions/string.dart @@ -9,3 +9,9 @@ extension UnescapeHtml on String { extension NullableUnescapeHtml on String? { String? unescapeHtml() => this == null ? null : htmlEscape.convert(this!); } + +extension StringExtension on String { + String capitalize() { + return "${this[0].toUpperCase()}${substring(1)}"; + } +} diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart index 724fb346..cbab03b9 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -8,13 +8,20 @@ import 'package:spotube/pages/getting_started/sections/greeting.dart'; import 'package:spotube/pages/getting_started/sections/playback.dart'; import 'package:spotube/pages/getting_started/sections/region.dart'; import 'package:spotube/pages/getting_started/sections/support.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/themes/theme.dart'; class GettingStarting extends HookConsumerWidget { const GettingStarting({super.key}); @override Widget build(BuildContext context, ref) { - final ThemeData(:colorScheme) = Theme.of(context); + final preferences = ref.watch(userPreferencesProvider); + final themeData = theme( + preferences.accentColorScheme, + Brightness.dark, + preferences.amoledDarkTheme, + ); final pageController = usePageController(); final onNext = useCallback(() { @@ -31,63 +38,66 @@ class GettingStarting extends HookConsumerWidget { ); }, [pageController]); - return Scaffold( - appBar: PageWindowTitleBar( - backgroundColor: Colors.transparent, - actions: [ - ListenableBuilder( - listenable: pageController, - builder: (context, _) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: pageController.hasClients && - (pageController.page == 0 || pageController.page == 3) - ? const SizedBox() - : TextButton( - onPressed: () { - pageController.animateToPage( - 3, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - child: Text( - context.l10n.skip_this_nonsense, - style: TextStyle( - decoration: TextDecoration.underline, - decorationColor: colorScheme.primary, + return Theme( + data: themeData, + child: Scaffold( + appBar: PageWindowTitleBar( + backgroundColor: Colors.transparent, + actions: [ + ListenableBuilder( + listenable: pageController, + builder: (context, _) { + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: pageController.hasClients && + (pageController.page == 0 || pageController.page == 3) + ? const SizedBox() + : TextButton( + onPressed: () { + pageController.animateToPage( + 3, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + child: Text( + context.l10n.skip_this_nonsense, + style: TextStyle( + decoration: TextDecoration.underline, + decorationColor: themeData.colorScheme.primary, + ), ), ), - ), - ); - }, - ), - ], - ), - extendBodyBehindAppBar: true, - body: DecoratedBox( - decoration: BoxDecoration( - image: DecorationImage( - image: Assets.bengaliPatternsBg.provider(), - fit: BoxFit.cover, - colorFilter: ColorFilter.mode( - colorScheme.background.withOpacity(0.2), - BlendMode.srcOver, + ); + }, ), - ), - ), - child: PageView( - controller: pageController, - children: [ - GettingStartedPageGreetingSection(onNext: onNext), - GettingStartedPageLanguageRegionSection(onNext: onNext), - GettingStartedPagePlaybackSection( - onNext: onNext, - onPrevious: onPrevious, - ), - const GettingStartedScreenSupportSection(), ], ), + extendBodyBehindAppBar: true, + body: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: Assets.bengaliPatternsBg.provider(), + fit: BoxFit.cover, + colorFilter: const ColorFilter.mode( + Colors.black38, + BlendMode.srcOver, + ), + ), + ), + child: PageView( + controller: pageController, + children: [ + GettingStartedPageGreetingSection(onNext: onNext), + GettingStartedPageLanguageRegionSection(onNext: onNext), + GettingStartedPagePlaybackSection( + onNext: onNext, + onPrevious: onPrevious, + ), + const GettingStartedScreenSupportSection(), + ], + ), + ), ), ); } diff --git a/lib/pages/getting_started/sections/playback.dart b/lib/pages/getting_started/sections/playback.dart index e94a06cc..298cf839 100644 --- a/lib/pages/getting_started/sections/playback.dart +++ b/lib/pages/getting_started/sections/playback.dart @@ -6,6 +6,7 @@ import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/string.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; @@ -87,7 +88,7 @@ class GettingStartedPagePlaybackSection extends HookConsumerWidget { audioSourceToIconMap[source]!, const Gap(8), Text( - source.name, + source.name.capitalize(), style: textTheme.bodySmall!.copyWith( color: preferences.audioSource == source ? colorScheme.primary diff --git a/lib/services/audio_player/mk_state_player.dart b/lib/services/audio_player/mk_state_player.dart index 04df7111..8b796d66 100644 --- a/lib/services/audio_player/mk_state_player.dart +++ b/lib/services/audio_player/mk_state_player.dart @@ -17,13 +17,6 @@ class MkPlayerWithState extends Player { final StreamController _shuffleStream; final StreamController _loopModeStream; - static const String EXTRA_PACKAGE_NAME = "android.media.extra.PACKAGE_NAME"; - static const String EXTRA_AUDIO_SESSION = "android.media.extra.AUDIO_SESSION"; - static const String ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION = - "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION"; - static const String ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION = - "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION"; - late final List _subscriptions; bool _shuffled; @@ -87,23 +80,28 @@ class MkPlayerWithState extends Player { await _androidAudioManager!.generateAudioSessionId(); notifyAudioSessionUpdate(true); - nativePlayer.setProperty( - "audiotrack-session-id", _androidAudioSessionId.toString()); - nativePlayer.setProperty("ao", "audiotrack,opensles,"); + await nativePlayer.setProperty( + "audiotrack-session-id", + _androidAudioSessionId.toString(), + ); + await nativePlayer.setProperty("ao", "audiotrack,opensles,"); }); } } Future notifyAudioSessionUpdate(bool active) async { if (DesktopTools.platform.isAndroid) { - sendBroadcast(BroadcastMessage( + sendBroadcast( + BroadcastMessage( name: active - ? ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION - : ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION, + ? "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION" + : "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION", data: { - EXTRA_AUDIO_SESSION: _androidAudioSessionId, - EXTRA_PACKAGE_NAME: _packageName - })); + "android.media.extra.AUDIO_SESSION": _androidAudioSessionId, + "android.media.extra.PACKAGE_NAME": _packageName + }, + ), + ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 5ccc5bb1..04d3f1a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.4.1+28 +version: 3.5.0+29 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube From a6164c5791eef6f66b9b7585a0b3ab9a8749835b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 8 Mar 2024 20:53:04 +0600 Subject: [PATCH 007/261] chore: add untranslated messages --- bin/translated_messages.dart | 26 ++++++++++++++++++++++++++ lib/l10n/app_ar.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_bn.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_ca.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_de.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_es.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_fa.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_fr.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_hi.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_it.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_ja.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_ko.arb | 31 +++++++++++++++++++++++++++++-- lib/l10n/app_ne.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_nl.arb | 32 ++++++++++++++++++++++++++++++-- lib/l10n/app_pl.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_pt.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_ru.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_tr.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_uk.arb | 29 ++++++++++++++++++++++++++++- lib/l10n/app_vi.arb | 31 +++++++++++++++++++++++++++++-- lib/l10n/app_zh.arb | 29 ++++++++++++++++++++++++++++- 21 files changed, 590 insertions(+), 23 deletions(-) create mode 100644 bin/translated_messages.dart diff --git a/bin/translated_messages.dart b/bin/translated_messages.dart new file mode 100644 index 00000000..0de398df --- /dev/null +++ b/bin/translated_messages.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; +import 'dart:io'; + +void main(List args) async { + final translatedFile = + jsonDecode(await File('tm.json').readAsString()) as Map; + + for (final MapEntry(:key, :value) in translatedFile.entries) { + print('Updating locale: $key'); + final file = File('lib/l10n/app_$key.arb'); + + final fileContent = + jsonDecode(await file.readAsString()) as Map; + + final newContent = { + ...fileContent, + ...value, + }; + + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(newContent), + ); + + print('✅ Updated locale: $key'); + } +} diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index eebede99..41fab083 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -286,5 +286,32 @@ "step_3_steps": "انسخ قيمة الكوكي \"sp_dc\"", "step_4_steps": "الصق قيمة \"sp_dc\" المنسوخة", "friends": "أصدقاء", - "no_lyrics_available": "عذرًا، تعذر العثور على كلمات الأغنية لهذه العنصر" + "no_lyrics_available": "عذرًا، تعذر العثور على كلمات الأغنية لهذه العنصر", + "sort_duration": "ترتيب حسب المدة", + "start_a_radio": "بدء راديو", + "how_to_start_radio": "كيف تريد بدء الراديو؟", + "replace_queue_question": "هل تريد استبدال قائمة التشغيل الحالية أم إضافة إليها؟", + "endless_playback": "تشغيل بلا نهاية", + "delete_playlist": "حذف قائمة التشغيل", + "delete_playlist_confirmation": "هل أنت متأكد أنك تريد حذف هذه قائمة التشغيل؟", + "local_tracks": "المسارات المحلية", + "song_link": "رابط الأغنية", + "skip_this_nonsense": "تخطي هذه الهراء", + "freedom_of_music": "“حرية الموسيقى”", + "freedom_of_music_palm": "“حرية الموسيقى في متناول يدك”", + "get_started": "لنبدأ", + "youtube_source_description": "موصى به ويعمل بشكل أفضل.", + "piped_source_description": "تشعر بالحرية؟ نفس يوتيوب ولكن أكثر حرية.", + "jiosaavn_source_description": "الأفضل لمنطقة جنوب آسيا.", + "highest_quality": "أعلى جودة: {quality}", + "select_audio_source": "اختر مصدر الصوت", + "endless_playback_description": "إلحاق الأغاني الجديدة تلقائيًا\nإلى نهاية قائمة التشغيل", + "choose_your_region": "اختر منطقتك", + "choose_your_region_description": "سيساعدك هذا في عرض المحتوى المناسب\nلموقعك.", + "choose_your_language": "اختر لغتك", + "help_project_grow": "ساعد في نمو هذا المشروع", + "help_project_grow_description": "Spotube هو مشروع مفتوح المصدر. يمكنك مساعدة هذا المشروع في النمو عن طريق المساهمة في المشروع، أو الإبلاغ عن الأخطاء، أو اقتراح ميزات جديدة.", + "contribute_on_github": "المساهمة على GitHub", + "donate_on_open_collective": "التبرع على Open Collective", + "browse_anonymously": "تصفح بشكل مجهول" } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 2711f8d2..353ca617 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -286,5 +286,32 @@ "step_3_steps": "কুকি \"sp_dc\" এর মানটি কপি করুন", "step_4_steps": "কপি করা \"sp_dc\" মানটি পেস্ট করুন", "friends": "বন্ধু", - "no_lyrics_available": "দুঃখিত, এই ট্র্যাকের জন্য কথা খুঁজে পাওয়া গেলনা" + "no_lyrics_available": "দুঃখিত, এই ট্র্যাকের জন্য কথা খুঁজে পাওয়া গেলনা", + "sort_duration": "দৈর্ঘ্য অনুযায়ী বাছাই করুন", + "start_a_radio": "রেডিও শুরু করুন", + "how_to_start_radio": "রেডিও কিভাবে শুরু করতে চান?", + "replace_queue_question": "আপনি বর্তমান কিউটি প্রতিস্থাপন করতে চান কিনা বা এর সাথে যুক্ত করতে চান?", + "endless_playback": "অবিরাম প্রচার", + "delete_playlist": "প্লেলিস্ট মুছুন", + "delete_playlist_confirmation": "আপনি কি নিশ্চিত যে আপনি এই প্লেলিস্টটি মুছতে চান?", + "local_tracks": "স্থানীয় ট্র্যাক", + "song_link": "গানের লিংক", + "skip_this_nonsense": "এই বাকবাস পালান", + "freedom_of_music": "“সংগীতের স্বাধীনতা”", + "freedom_of_music_palm": "“তোমার হাতের কাছে সংগীতের স্বাধীনতা”", + "get_started": "শুরু করা যাক", + "youtube_source_description": "প্রস্তাবিত এবং সেরা কাজ করে।", + "piped_source_description": "মন খারাপ? ইউটিউবের মতো আবার ফ্রি।", + "jiosaavn_source_description": "দক্ষিণ এশিয়ান অঞ্চলের জন্য সেরা।", + "highest_quality": "সর্বোচ্চ গুণগতি: {quality}", + "select_audio_source": "অডিও উৎস নির্বাচন করুন", + "endless_playback_description": "নতুন গান নিজে নিজে প্লেলিস্টের শেষে\nসংযুক্ত করুন", + "choose_your_region": "আপনার অঞ্চল নির্বাচন করুন", + "choose_your_region_description": "এটি স্পটুবে আপনাকে আপনার অবস্থানের জন্য ঠিক কন্টেন্ট দেখানোর সাহায্য করবে।", + "choose_your_language": "আপনার ভাষা নির্বাচন করুন", + "help_project_grow": "এই প্রকল্পের বৃদ্ধি করুন", + "help_project_grow_description": "স্পটুব একটি ওপেন সোর্স প্রকল্প। আপনি প্রকল্পে অবদান রাখেন, বাগ রিপোর্ট করেন, বা নতুন বৈশিষ্ট্যগুলি সুপারিশ করেন।", + "contribute_on_github": "গিটহাবে অবদান রাখুন", + "donate_on_open_collective": "ওপেন কলেক্টিভে অনুদান করুন", + "browse_anonymously": "অজানে ব্রাউজ করুন" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index f46cfae4..9848954a 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -286,5 +286,32 @@ "step_3_steps": "Copia el valor de la cookie \"sp_dc\"", "step_4_steps": "Pega el valor copiado de \"sp_dc\"", "friends": "Amics", - "no_lyrics_available": "Ho sentim, no es poden trobar les lletres d'aquesta pista" + "no_lyrics_available": "Ho sentim, no es poden trobar les lletres d'aquesta pista", + "sort_duration": "Ordenar per Durada", + "start_a_radio": "Inicia una ràdio", + "how_to_start_radio": "Com vols començar la ràdio?", + "replace_queue_question": "Voleu substituir la cua actual o afegir-hi?", + "endless_playback": "Reproducció infinita", + "delete_playlist": "Suprimeix la llista de reproducció", + "delete_playlist_confirmation": "Esteu segur que voleu suprimir aquesta llista de reproducció?", + "local_tracks": "Pistes locals", + "song_link": "Enllaç de la cançó", + "skip_this_nonsense": "Omet aquesta tonteria", + "freedom_of_music": "“Llibertat de la música”", + "freedom_of_music_palm": "“Llibertat de la música a la palma de la mà”", + "get_started": "Comencem", + "youtube_source_description": "Recomanat i funciona millor.", + "piped_source_description": "Et sents lliure? El mateix que YouTube però més lliure.", + "jiosaavn_source_description": "El millor per a la regió del sud d'Àsia.", + "highest_quality": "Qualitat més alta: {quality}", + "select_audio_source": "Seleccioneu la font d'àudio", + "endless_playback_description": "Afegiu automàticament noves cançons\nal final de la cua", + "choose_your_region": "Trieu la vostra regió", + "choose_your_region_description": "Això ajudarà a Spotube a mostrar-vos el contingut adequat\nper a la vostra ubicació.", + "choose_your_language": "Trieu el vostre idioma", + "help_project_grow": "Ajuda a fer créixer aquest projecte", + "help_project_grow_description": "Spotube és un projecte de codi obert. Podeu ajudar a fer créixer aquest projecte contribuint al projecte, informant d'errors o suggerint noves funcionalitats.", + "contribute_on_github": "Contribueix a GitHub", + "donate_on_open_collective": "Fes una donació a Open Collective", + "browse_anonymously": "Navega de manera anònima" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index ebaa0329..b058d41a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -286,5 +286,32 @@ "step_3_steps": "Kopiere den Wert des Cookies \"sp_dc\"", "step_4_steps": "Füge den kopierten Wert von \"sp_dc\" ein", "friends": "Freunde", - "no_lyrics_available": "Entschuldigung, Texte für diesen Track konnten nicht gefunden werden" + "no_lyrics_available": "Entschuldigung, Texte für diesen Track konnten nicht gefunden werden", + "sort_duration": "Nach Dauer sortieren", + "start_a_radio": "Radio starten", + "how_to_start_radio": "Wie möchten Sie das Radio starten?", + "replace_queue_question": "Möchten Sie die aktuelle Wiedergabeliste ersetzen oder hinzufügen?", + "endless_playback": "Endlose Wiedergabe", + "delete_playlist": "Wiedergabeliste löschen", + "delete_playlist_confirmation": "Sind Sie sicher, dass Sie diese Wiedergabeliste löschen möchten?", + "local_tracks": "Lokale Titel", + "song_link": "Lied-Link", + "skip_this_nonsense": "Diesen Unsinn überspringen", + "freedom_of_music": "“Freiheit der Musik”", + "freedom_of_music_palm": "“Freiheit der Musik in Ihrer Handfläche”", + "get_started": "Lass uns anfangen", + "youtube_source_description": "Empfohlen und funktioniert am besten.", + "piped_source_description": "Fühlen Sie sich frei? Wie YouTube, aber viel freier.", + "jiosaavn_source_description": "Am besten für die südasiatische Region.", + "highest_quality": "Höchste Qualität: {quality}", + "select_audio_source": "Audioquelle auswählen", + "endless_playback_description": "Neue Lieder automatisch\nam Ende der Wiedergabeliste hinzufügen", + "choose_your_region": "Wählen Sie Ihre Region", + "choose_your_region_description": "Dies wird Spotube helfen, Ihnen den richtigen Inhalt\nfür Ihren Standort anzuzeigen.", + "choose_your_language": "Wählen Sie Ihre Sprache", + "help_project_grow": "Helfen Sie diesem Projekt zu wachsen", + "help_project_grow_description": "Spotube ist ein Open-Source-Projekt. Sie können diesem Projekt helfen, indem Sie zum Projekt beitragen, Fehler melden oder neue Funktionen vorschlagen.", + "contribute_on_github": "Auf GitHub beitragen", + "donate_on_open_collective": "Auf Open Collective spenden", + "browse_anonymously": "Anonym durchsuchen" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 476056cb..0b4cbb2a 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -286,5 +286,32 @@ "step_3_steps": "Copia el valor de la cookie \"sp_dc\"", "step_4_steps": "Pega el valor copiado de \"sp_dc\"", "friends": "Amigos", - "no_lyrics_available": "Lo siento, no se pueden encontrar las letras de esta pista" + "no_lyrics_available": "Lo siento, no se pueden encontrar las letras de esta pista", + "sort_duration": "Ordenar por Duración", + "start_a_radio": "Iniciar una Radio", + "how_to_start_radio": "¿Cómo quieres iniciar la radio?", + "replace_queue_question": "¿Quieres reemplazar la lista de reproducción actual o añadir a ella?", + "endless_playback": "Reproducción Infinita", + "delete_playlist": "Eliminar Lista de Reproducción", + "delete_playlist_confirmation": "¿Estás seguro de que quieres eliminar esta lista de reproducción?", + "local_tracks": "Pistas Locales", + "song_link": "Enlace de la Canción", + "skip_this_nonsense": "Saltar esta tontería", + "freedom_of_music": "“Libertad de la Música”", + "freedom_of_music_palm": "“Libertad de la Música en la palma de tu mano”", + "get_started": "Empecemos", + "youtube_source_description": "Recomendado y funciona mejor.", + "piped_source_description": "¿Te sientes libre? Igual que YouTube pero más libre.", + "jiosaavn_source_description": "Lo mejor para la región del sur de Asia.", + "highest_quality": "Mayor Calidad: {quality}", + "select_audio_source": "Seleccionar Fuente de Audio", + "endless_playback_description": "Añadir automáticamente nuevas canciones\nal final de la cola de reproducción", + "choose_your_region": "Elige tu región", + "choose_your_region_description": "Esto ayudará a Spotube a mostrarte el contenido adecuado\npara tu ubicación.", + "choose_your_language": "Elige tu idioma", + "help_project_grow": "Ayuda a que este proyecto crezca", + "help_project_grow_description": "Spotube es un proyecto de código abierto. Puedes ayudar a que este proyecto crezca contribuyendo al proyecto, informando errores o sugiriendo nuevas funciones.", + "contribute_on_github": "Contribuir en GitHub", + "donate_on_open_collective": "Donar en Open Collective", + "browse_anonymously": "Navegar Anónimamente" } \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 3a2bcb4b..629238cc 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -286,5 +286,32 @@ "step_3_steps": "مقدار کوکی \"sp_dc\" را کپی کنید", "step_4_steps": "مقدار کپی شده \"sp_dc\" را الصاق کنید", "friends": "دوستان", - "no_lyrics_available": "متاسفیم، قادر به یافتن متن این قطعه نیستیم" + "no_lyrics_available": "متاسفیم، قادر به یافتن متن این قطعه نیستیم", + "sort_duration": "مرتب کردن بر اساس مدت زمان", + "start_a_radio": "شروع یک رادیو", + "how_to_start_radio": "چگونه می‌خواهید رادیو را شروع کنید؟", + "replace_queue_question": "آیا می‌خواهید لیست پخش فعلی را جایگزین کنید یا به آن اضافه کنید؟", + "endless_playback": "پخش بی‌پایان", + "delete_playlist": "حذف لیست پخش", + "delete_playlist_confirmation": "آیا مطمئن هستید که می‌خواهید این لیست پخش را حذف کنید؟", + "local_tracks": "موسیقی‌های محلی", + "song_link": "پیوند آهنگ", + "skip_this_nonsense": "این احمقانه را بگذرانید", + "freedom_of_music": "“آزادی موسیقی”", + "freedom_of_music_palm": "“آزادی موسیقی در دستان شما”", + "get_started": "بیایید شروع کنیم", + "youtube_source_description": "پیشنهاد شده و بهترین عمل می‌کند.", + "piped_source_description": "احساس آزادی می‌کنید؟ مانند یوتیوب اما بیشتر آزاد.", + "jiosaavn_source_description": "بهترین برای منطقه جنوب آسیا.", + "highest_quality": "بالاترین کیفیت: {quality}", + "select_audio_source": "انتخاب منبع صوتی", + "endless_playback_description": "خودکار اضافه کردن آهنگ‌های جدید\nبه انتهای صف", + "choose_your_region": "منطقه خود را انتخاب کنید", + "choose_your_region_description": "این به Spotube کمک می‌کند تا محتوای مناسبی را برای موقعیت شما نشان دهد.", + "choose_your_language": "زبان خود را انتخاب کنید", + "help_project_grow": "کمک به رشد این پروژه", + "help_project_grow_description": "Spotube یک پروژه متن باز است. شما می‌توانید با به پروژه کمک کردن، گزارش دادن اشکالات یا پیشنهاد ویژگی‌های جدید، به این پروژه کمک کنید.", + "contribute_on_github": "مشارکت در GitHub", + "donate_on_open_collective": "کمک مالی در Open Collective", + "browse_anonymously": "مرور به صورت ناشناس" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 5c24d0fe..69b2bb69 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -286,5 +286,32 @@ "step_3_steps": "Copiez la valeur du cookie \"sp_dc\"", "step_4_steps": "Collez la valeur copiée de \"sp_dc\"", "friends": "Amis", - "no_lyrics_available": "Désolé, impossible de trouver les paroles de cette piste" + "no_lyrics_available": "Désolé, impossible de trouver les paroles de cette piste", + "sort_duration": "Trier par durée", + "start_a_radio": "Démarrer une radio", + "how_to_start_radio": "Comment voulez-vous démarrer la radio ?", + "replace_queue_question": "Voulez-vous remplacer la file d'attente actuelle ou y ajouter ?", + "endless_playback": "Lecture sans fin", + "delete_playlist": "Supprimer la playlist", + "delete_playlist_confirmation": "Êtes-vous sûr de vouloir supprimer cette playlist ?", + "local_tracks": "Titres locaux", + "song_link": "Lien de la chanson", + "skip_this_nonsense": "Passer cette absurdité", + "freedom_of_music": "“Liberté de la musique”", + "freedom_of_music_palm": "“Liberté de la musique dans la paume de votre main”", + "get_started": "Commençons", + "youtube_source_description": "Recommandé et fonctionne mieux.", + "piped_source_description": "Vous vous sentez libre ? Comme YouTube mais beaucoup plus gratuit.", + "jiosaavn_source_description": "Le meilleur pour la région d'Asie du Sud.", + "highest_quality": "Meilleure qualité : {quality}", + "select_audio_source": "Sélectionner la source audio", + "endless_playback_description": "Ajouter automatiquement de nouvelles chansons à la fin de la file d'attente", + "choose_your_region": "Choisissez votre région", + "choose_your_region_description": "Cela aidera Spotube à vous montrer le bon contenu pour votre emplacement.", + "choose_your_language": "Choisissez votre langue", + "help_project_grow": "Aidez ce projet à grandir", + "help_project_grow_description": "Spotube est un projet open-source. Vous pouvez aider ce projet à grandir en contribuant au projet, en signalant des bugs ou en suggérant de nouvelles fonctionnalités.", + "contribute_on_github": "Contribuer sur GitHub", + "donate_on_open_collective": "Faire un don sur Open Collective", + "browse_anonymously": "Naviguer anonymement" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 1cf62398..b442da37 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -286,5 +286,32 @@ "step_3_steps": "\"sp_dc\" कुकी का मूल्य कॉपी करें", "step_4_steps": "कॉपी किए गए \"sp_dc\" मूल्य को पेस्ट करें", "friends": "दोस्त", - "no_lyrics_available": "क्षमा करें, इस ट्रैक के लिए गाने नहीं मिल सके" + "no_lyrics_available": "क्षमा करें, इस ट्रैक के लिए गाने नहीं मिल सके", + "sort_duration": "समय के आधार पर क्रमबद्ध करें", + "start_a_radio": "रेडियो शुरू करें", + "how_to_start_radio": "रेडियो कैसे शुरू करना चाहते हैं?", + "replace_queue_question": "क्या आप वर्तमान कतार को बदलना चाहते हैं या इसे जोड़ना चाहते हैं?", + "endless_playback": "अंतहीन प्लेबैक", + "delete_playlist": "प्लेलिस्ट हटाएं", + "delete_playlist_confirmation": "क्या आप वाकई इस प्लेलिस्ट को हटाना चाहते हैं?", + "local_tracks": "स्थानीय ट्रैक्स", + "song_link": "गाने का लिंक", + "skip_this_nonsense": "इस माया को छोड़ें", + "freedom_of_music": "“संगीत की स्वतंत्रता”", + "freedom_of_music_palm": "“हाथ में संगीत की स्वतंत्रता”", + "get_started": "आइए शुरू करें", + "youtube_source_description": "सिफारिश किया गया और सबसे अच्छा काम करता है।", + "piped_source_description": "मुफ्त महसूस कर रहे हैं? YouTube के समान लेकिन काफी अधिक मुफ्त।", + "jiosaavn_source_description": "दक्षिण एशियाई क्षेत्र के लिए सर्वोत्तम।", + "highest_quality": "सर्वोत्तम गुणवत्ता: {quality}", + "select_audio_source": "ऑडियो स्रोत चुनें", + "endless_playback_description": "क्रमबद्ध कतार के अंत में नए गाने स्वचालित रूप से जोड़ें", + "choose_your_region": "अपना क्षेत्र चुनें", + "choose_your_region_description": "यह Spotube को आपके स्थान के लिए सही सामग्री दिखाने में मदद करेगा।", + "choose_your_language": "अपनी भाषा चुनें", + "help_project_grow": "इस परियोजना को बढ़ावा दें", + "help_project_grow_description": "Spotube एक ओपन सोर्स परियोजना है। आप इस परियोजना को योगदान देकर, बग रिपोर्ट करके या नई विशेषताओं का सुझाव देकर इस परियोजना को बढ़ा सकते हैं।", + "contribute_on_github": "GitHub पर योगदान करें", + "donate_on_open_collective": "ओपन कलेक्टिव पर दान करें", + "browse_anonymously": "बिना नाम के ब्राउज़ करें" } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index ec76b914..f8440cd0 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -287,5 +287,32 @@ "step_3_steps": "Copia il valore del cookie \"sp_dc\"", "step_4_steps": "Incolla il valore copiato di \"sp_dc\"", "friends": "Amici", - "no_lyrics_available": "Spiacente, impossibile trovare il testo di questa traccia" + "no_lyrics_available": "Spiacente, impossibile trovare il testo di questa traccia", + "sort_duration": "Ordina per Durata", + "start_a_radio": "Avvia una Radio", + "how_to_start_radio": "Come vuoi avviare la radio?", + "replace_queue_question": "Vuoi sostituire la coda attuale o aggiungerla?", + "endless_playback": "Riproduzione Infinita", + "delete_playlist": "Elimina Playlist", + "delete_playlist_confirmation": "Sei sicuro di voler eliminare questa playlist?", + "local_tracks": "Tracce Locali", + "song_link": "Link della Canzone", + "skip_this_nonsense": "Salta questa sciocchezza", + "freedom_of_music": "“Libertà della Musica”", + "freedom_of_music_palm": "“Libertà della Musica nel palmo della tua mano”", + "get_started": "Cominciamo", + "youtube_source_description": "Consigliato e funziona meglio.", + "piped_source_description": "Ti senti libero? Come YouTube ma molto più gratuito.", + "jiosaavn_source_description": "Il migliore per la regione dell'Asia meridionale.", + "highest_quality": "Massima Qualità: {quality}", + "select_audio_source": "Seleziona Sorgente Audio", + "endless_playback_description": "Aggiungi automaticamente nuove canzoni alla fine della coda", + "choose_your_region": "Scegli la tua regione", + "choose_your_region_description": "Questo aiuterà Spotube a mostrarti il contenuto giusto per la tua posizione.", + "choose_your_language": "Scegli la tua lingua", + "help_project_grow": "Aiuta questo progetto a crescere", + "help_project_grow_description": "Spotube è un progetto open-source. Puoi aiutare questo progetto a crescere contribuendo al progetto, segnalando bug o suggerendo nuove funzionalità.", + "contribute_on_github": "Contribuisci su GitHub", + "donate_on_open_collective": "Dona su Open Collective", + "browse_anonymously": "Naviga in modo anonimo" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index d16708d7..ecdc77a2 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -286,5 +286,32 @@ "step_3_steps": "\"sp_dc\" Cookieの値をコピー", "step_4_steps": "コピーした\"sp_dc\"の値を貼り付け", "friends": "友達", - "no_lyrics_available": "申し訳ありませんが、このトラックの歌詞を見つけることができません" + "no_lyrics_available": "申し訳ありませんが、このトラックの歌詞を見つけることができません", + "sort_duration": "時間で並べ替え", + "start_a_radio": "ラジオを開始", + "how_to_start_radio": "ラジオをどのように開始しますか?", + "replace_queue_question": "現在のキューを置き換えるか、追加しますか?", + "endless_playback": "エンドレス再生", + "delete_playlist": "プレイリストを削除", + "delete_playlist_confirmation": "このプレイリストを削除してもよろしいですか?", + "local_tracks": "ローカルトラック", + "song_link": "曲のリンク", + "skip_this_nonsense": "この愚かなことをスキップ", + "freedom_of_music": "“音楽の自由”", + "freedom_of_music_palm": "“手のひらの中の音楽の自由”", + "get_started": "さあ始めましょう", + "youtube_source_description": "推奨され、最適に機能します。", + "piped_source_description": "自由に感じますか? YouTubeと同じですが、はるかに無料です。", + "jiosaavn_source_description": "南アジア地域向けの最適です。", + "highest_quality": "最高品質:{quality}", + "select_audio_source": "オーディオソースを選択", + "endless_playback_description": "新しい曲をキューの最後に自動的に追加", + "choose_your_region": "地域を選択", + "choose_your_region_description": "これにより、Spotubeがあなたの場所に適したコンテンツを表示できます。", + "choose_your_language": "言語を選択してください", + "help_project_grow": "このプロジェクトの成長を支援する", + "help_project_grow_description": "Spotubeはオープンソースプロジェクトです。プロジェクトに貢献したり、バグを報告したり、新しい機能を提案することで、このプロジェクトの成長に貢献できます。", + "contribute_on_github": "GitHubで貢献する", + "donate_on_open_collective": "Open Collectiveで寄付する", + "browse_anonymously": "匿名で閲覧する" } \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index dac5b72a..5a3ee8bc 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -287,5 +287,32 @@ "step_4_steps": "복사한 \"sp_dc\"값을 붙여넣기", "friends": "친구", "no_lyrics_available": "죄송하지만 이 곡의 가사를 찾지 못했습니다", - "@@locale": "ko" -} + "@@locale": "ko", + "sort_duration": "시간순 정렬", + "start_a_radio": "라디오 시작", + "how_to_start_radio": "라디오를 어떻게 시작하시겠습니까?", + "replace_queue_question": "현재 큐를 대체하시겠습니까 아니면 추가하시겠습니까?", + "endless_playback": "끝없는 재생", + "delete_playlist": "재생 목록 삭제", + "delete_playlist_confirmation": "이 재생 목록을 삭제하시겠습니까?", + "local_tracks": "로컬 트랙", + "song_link": "곡 링크", + "skip_this_nonsense": "이 허튼소리 건너뛰기", + "freedom_of_music": "“음악의 자유”", + "freedom_of_music_palm": "“손바닥 안의 음악의 자유”", + "get_started": "시작합시다", + "youtube_source_description": "추천되며 가장 잘 작동합니다.", + "piped_source_description": "자유로운 기분이 듭니까? YouTube와 같지만 훨씬 더 무료합니다.", + "jiosaavn_source_description": "남아시아 지역에 최적입니다.", + "highest_quality": "최고 품질: {quality}", + "select_audio_source": "오디오 소스 선택", + "endless_playback_description": "자동으로 새로운 노래를 대기열의 끝에 추가", + "choose_your_region": "지역 선택", + "choose_your_region_description": "이것은 Spotube가 위치에 맞는 콘텐츠를 표시하는 데 도움이 됩니다.", + "choose_your_language": "언어 선택", + "help_project_grow": "이 프로젝트 성장에 도움을 주세요", + "help_project_grow_description": "Spotube는 오픈 소스 프로젝트입니다. 프로젝트에 기여하거나 버그를 보고하거나 새로운 기능을 제안하여이 프로젝트의 성장에 도움을 줄 수 있습니다.", + "contribute_on_github": "GitHub에서 기여하기", + "donate_on_open_collective": "Open Collective에 기부하기", + "browse_anonymously": "익명으로 둘러보기" +} \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb index 2d20fc9c..d921f3ba 100644 --- a/lib/l10n/app_ne.arb +++ b/lib/l10n/app_ne.arb @@ -286,5 +286,32 @@ "genres": "शैलीहरू", "explore_genres": "शैलीहरू अन्वेषण गर्नुहोस्", "friends": "साथीहरू", - "no_lyrics_available": "क्षमा गर्दैछौं, यस ट्र्याकका लागि गीतका शब्दहरू फेला परेन" + "no_lyrics_available": "क्षमा गर्दैछौं, यस ट्र्याकका लागि गीतका शब्दहरू फेला परेन", + "sort_duration": "अवधिको अनुसार क्रमबद्ध गर्नुहोस्", + "start_a_radio": "रेडियो सुरु गर्नुहोस्", + "how_to_start_radio": "तपाईं रेडियो कसरी सुरु गर्न चाहानुहुन्छ?", + "replace_queue_question": "के तपाईं वर्तमान कताक्ष कोट बदल्न चाहानुहुन्छ वा यसलाई थप्नुहुन्छ?", + "endless_playback": "अनन्त प्लेब्याक", + "delete_playlist": "प्लेलिस्ट मेटाउनुहोस्", + "delete_playlist_confirmation": "के तपाईं यो प्लेलिस्ट मेटाउन निश्चित हुनुहुन्छ?", + "local_tracks": "स्थानिय ट्र्याकहरू", + "song_link": "गीत लिंक", + "skip_this_nonsense": "यस अबश्यकता छोड्नुहोस्", + "freedom_of_music": "“संगीतको स्वतन्त्रता”", + "freedom_of_music_palm": "“तपाईंको हातमा संगीतको स्वतन्त्रता”", + "get_started": "आइयाँ प्रारम्भ गरौं", + "youtube_source_description": "सिफारिस गरिएको र बेस्ट काम गर्दछ।", + "piped_source_description": "मुक्त सुस्त? YouTube जस्तै तर धेरै मुक्त।", + "jiosaavn_source_description": "दक्षिण एशियाली क्षेत्रको लागि सर्वोत्तम।", + "highest_quality": "उच्चतम गुणस्तर: {quality}", + "select_audio_source": "आडियो स्रोत चयन गर्नुहोस्", + "endless_playback_description": "नयाँ गीतहरूलाई स्वचालित रूपमा कताक्षको अन्तमा जोड्नुहोस्", + "choose_your_region": "तपाईंको क्षेत्र छनौट गर्नुहोस्", + "choose_your_region_description": "यो Spotubeलाई तपाईंको स्थानका लागि सहि सामग्री देखाउने मद्दत गर्नेछ।", + "choose_your_language": "तपाईंको भाषा छनौट गर्नुहोस्", + "help_project_grow": "यस परियोजनामा वृद्धि गराउनुहोस्", + "help_project_grow_description": "Spotube एक खुला स्रोतको परियोजना हो। तपाईं परियोजनामा योगदान गरेर, त्रुटिहरू सूचिकै, वा नयाँ सुविधाहरू सुझाव दिएर यस परियोजनामा वृद्धि गर्न सक्नुहुन्छ।", + "contribute_on_github": "GitHubमा योगदान गर्नुहोस्", + "donate_on_open_collective": "खुला संगठनमा दान गर्नुहोस्", + "browse_anonymously": "अनामित रूपमा ब्राउज़ गर्नुहोस्" } \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 3bece8be..33e94a2e 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -286,5 +286,33 @@ "genres": "Genres", "explore_genres": "Genres verkennen", "friends": "Vrienden", - "no_lyrics_available": "Sorry, geen teksten gevonden voor dit nummer" -} + "no_lyrics_available": "Sorry, geen teksten gevonden voor dit nummer", + "sort_duration": "Sorteer op Duur", + "audio_source": "Audiobron", + "start_a_radio": "Start een Radio", + "how_to_start_radio": "Hoe wilt u de radio starten?", + "replace_queue_question": "Wilt u de huidige wachtrij vervangen of eraan toevoegen?", + "endless_playback": "Eindeloze Afspelen", + "delete_playlist": "Verwijder Afspeellijst", + "delete_playlist_confirmation": "Weet u zeker dat u deze afspeellijst wilt verwijderen?", + "local_tracks": "Lokale Nummers", + "song_link": "Nummer Link", + "skip_this_nonsense": "Sla deze onzin over", + "freedom_of_music": "“Vrijheid van Muziek”", + "freedom_of_music_palm": "“Vrijheid van Muziek in de palm van je hand”", + "get_started": "Laten we beginnen", + "youtube_source_description": "Aanbevolen en werkt het beste.", + "piped_source_description": "Voel je vrij? Hetzelfde als YouTube maar veel gratis.", + "jiosaavn_source_description": "Het beste voor de Zuid-Aziatische regio.", + "highest_quality": "Hoogste Kwaliteit: {quality}", + "select_audio_source": "Selecteer Audiobron", + "endless_playback_description": "Voeg automatisch nieuwe nummers toe aan het einde van de wachtrij", + "choose_your_region": "Kies uw regio", + "choose_your_region_description": "Dit zal Spotube helpen om de juiste inhoud voor uw locatie te tonen.", + "choose_your_language": "Kies uw taal", + "help_project_grow": "Help dit project groeien", + "help_project_grow_description": "Spotube is een open-source project. U kunt dit project helpen groeien door bij te dragen aan het project, bugs te melden of nieuwe functies voor te stellen.", + "contribute_on_github": "Bijdragen op GitHub", + "donate_on_open_collective": "Doneren op Open Collective", + "browse_anonymously": "Anoniem Bladeren" +} \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index b7ce8923..a1bc5de6 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -286,5 +286,32 @@ "step_3_steps": "Skopiuj wartość ciasteczka \"sp_dc\"", "step_4_steps": "Wklej skopiowaną wartość \"sp_dc\"", "friends": "Przyjaciele", - "no_lyrics_available": "Przepraszamy, nie można znaleźć tekstu dla tego utworu" + "no_lyrics_available": "Przepraszamy, nie można znaleźć tekstu dla tego utworu", + "sort_duration": "Sortuj według Czasu Trwania", + "start_a_radio": "Uruchom radio", + "how_to_start_radio": "Jak chcesz uruchomić radio?", + "replace_queue_question": "Czy chcesz zastąpić bieżącą kolejkę czy dodać do niej?", + "endless_playback": "Nieskończona Odtwarzanie", + "delete_playlist": "Usuń Playlistę", + "delete_playlist_confirmation": "Czy na pewno chcesz usunąć tę listę odtwarzania?", + "local_tracks": "Lokalne Utwory", + "song_link": "Link do Utworu", + "skip_this_nonsense": "Pomiń tę bzdurę", + "freedom_of_music": "“Wolność Muzyki”", + "freedom_of_music_palm": "“Wolność Muzyki w Twojej dłoni”", + "get_started": "Zacznijmy", + "youtube_source_description": "Polecane i działa najlepiej.", + "piped_source_description": "Czujesz się wolny? To samo co YouTube, ale dużo za darmo.", + "jiosaavn_source_description": "Najlepszy dla regionu Azji Południowej.", + "highest_quality": "Najwyższa Jakość: {quality}", + "select_audio_source": "Wybierz Źródło Audio", + "endless_playback_description": "Automatycznie dodaj nowe utwory na koniec kolejki", + "choose_your_region": "Wybierz swoją region", + "choose_your_region_description": "To pomoże Spotube pokazać Ci odpowiednią treść dla Twojej lokalizacji.", + "choose_your_language": "Wybierz swój język", + "help_project_grow": "Pomóż temu projektowi rosnąć", + "help_project_grow_description": "Spotube to projekt open-source. Możesz pomóc temu projektowi rosnąć, przyczyniając się do projektu, zgłaszając błędy lub sugerując nowe funkcje.", + "contribute_on_github": "Przyczyniaj się na GitHubie", + "donate_on_open_collective": "Dotuj na Open Collective", + "browse_anonymously": "Przeglądaj Anonimowo" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 1c75f734..7f290a1d 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -286,5 +286,32 @@ "step_3_steps": "Copie o valor do cookie \"sp_dc\"", "step_4_steps": "Cole o valor copiado de \"sp_dc\"", "friends": "Amigos", - "no_lyrics_available": "Desculpe, não foi possível encontrar a letra desta faixa" + "no_lyrics_available": "Desculpe, não foi possível encontrar a letra desta faixa", + "sort_duration": "Ordenar por Duração", + "start_a_radio": "Iniciar uma Rádio", + "how_to_start_radio": "Como você deseja iniciar a rádio?", + "replace_queue_question": "Você deseja substituir a fila atual ou acrescentar a ela?", + "endless_playback": "Reprodução sem fim", + "delete_playlist": "Excluir Lista de Reprodução", + "delete_playlist_confirmation": "Tem certeza de que deseja excluir esta lista de reprodução?", + "local_tracks": "Faixas Locais", + "song_link": "Link da Música", + "skip_this_nonsense": "Pular essa bobagem", + "freedom_of_music": "“Liberdade da Música”", + "freedom_of_music_palm": "“Liberdade da Música na palma da sua mão”", + "get_started": "Vamos começar", + "youtube_source_description": "Recomendado e funciona melhor.", + "piped_source_description": "Sentindo-se livre? Igual ao YouTube, mas muito mais grátis.", + "jiosaavn_source_description": "Melhor para a região da Ásia do Sul.", + "highest_quality": "Melhor Qualidade: {quality}", + "select_audio_source": "Selecionar Fonte de Áudio", + "endless_playback_description": "Adicionar automaticamente novas músicas\nao final da fila", + "choose_your_region": "Escolha sua região", + "choose_your_region_description": "Isso ajudará o Spotube a mostrar o conteúdo certo\npara sua localização.", + "choose_your_language": "Escolha seu idioma", + "help_project_grow": "Ajude este projeto a crescer", + "help_project_grow_description": "Spotube é um projeto de código aberto. Você pode ajudar este projeto a crescer contribuindo para o projeto, relatando bugs ou sugerindo novos recursos.", + "contribute_on_github": "Contribuir no GitHub", + "donate_on_open_collective": "Doar no Open Collective", + "browse_anonymously": "Navegar Anonimamente" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7ed67f4f..c9139a90 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -286,5 +286,32 @@ "step_3_steps": "Скопируйте значение файла cookie \"sp_dc\"", "step_4_steps": "Вставьте скопированное значение \"sp_dc\"", "friends": "Друзья", - "no_lyrics_available": "Извините, не удается найти текст для этого трека" + "no_lyrics_available": "Извините, не удается найти текст для этого трека", + "sort_duration": "Сортировка по Длительности", + "start_a_radio": "Запустить радио", + "how_to_start_radio": "Как вы хотите запустить радио?", + "replace_queue_question": "Хотите заменить текущую очередь или добавить к ней?", + "endless_playback": "Бесконечное воспроизведение", + "delete_playlist": "Удалить плейлист", + "delete_playlist_confirmation": "Вы уверены, что хотите удалить этот плейлист?", + "local_tracks": "Локальные треки", + "song_link": "Ссылка на песню", + "skip_this_nonsense": "Пропустить этот бред", + "freedom_of_music": "“Свобода музыки”", + "freedom_of_music_palm": "“Свобода музыки в вашей ладони”", + "get_started": "Начнем", + "youtube_source_description": "Рекомендуется и лучше всего работает.", + "piped_source_description": "Чувствуете себя свободно? То же самое, что и YouTube, но намного бесплатно.", + "jiosaavn_source_description": "Лучший для Южно-Азиатского региона.", + "highest_quality": "Наивысшее качество: {quality}", + "select_audio_source": "Выберите аудиоисточник", + "endless_playback_description": "Автоматически добавляйте новые песни\nв конец очереди", + "choose_your_region": "Выберите ваш регион", + "choose_your_region_description": "Это поможет Spotube показать вам правильный контент\nдля вашего местоположения.", + "choose_your_language": "Выберите ваш язык", + "help_project_grow": "Помогите этому проекту расти", + "help_project_grow_description": "Spotube - это проект с открытым исходным кодом. Вы можете помочь этому проекту развиваться, внося вклад в проект, сообщая ошибках или предлагая новые функции.", + "contribute_on_github": "Внести вклад на GitHub", + "donate_on_open_collective": "Пожертвовать на Open Collective", + "browse_anonymously": "Анонимно просматривать" } \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 4d9066fd..94800023 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -286,5 +286,32 @@ "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyala", "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştır", "friends": "Arkadaşlar", - "no_lyrics_available": "Üzgünüz, bu parça için şarkı sözleri bulunamıyor" + "no_lyrics_available": "Üzgünüz, bu parça için şarkı sözleri bulunamıyor", + "sort_duration": "Süreye Göre Sırala", + "start_a_radio": "Radyo Başlat", + "how_to_start_radio": "Radyoyu nasıl başlatmak istersiniz?", + "replace_queue_question": "Mevcut kuyruğu değiştirmek mi yoksa eklemek mi istersiniz?", + "endless_playback": "Sonsuz Çalma", + "delete_playlist": "Çalma Listesini Sil", + "delete_playlist_confirmation": "Bu çalma listesini silmek istediğinizden emin misiniz?", + "local_tracks": "Yerel Parçalar", + "song_link": "Şarkı Bağlantısı", + "skip_this_nonsense": "Bu saçmalığı atla", + "freedom_of_music": "“Müziğin Özgürlüğü”", + "freedom_of_music_palm": "“Müziğin Özgürlüğü avucunuzun içinde”", + "get_started": "Başlayalım", + "youtube_source_description": "Tavsiye edilir ve en iyi çalışır.", + "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı ama çok daha fazla ücretsiz.", + "jiosaavn_source_description": "Güney Asya bölgesi için en iyisi.", + "highest_quality": "En Yüksek Kalite: {quality}", + "select_audio_source": "Ses Kaynağını Seç", + "endless_playback_description": "Yeni şarkıları otomatik olarak sıraya ekle\nsonuna", + "choose_your_region": "Bölgenizi Seçin", + "choose_your_region_description": "Bu, Spotube'un konumunuza uygun doğru içeriği göstermesine yardımcı olacaktır.", + "choose_your_language": "Dilinizi Seçin", + "help_project_grow": "Bu projenin büyümesine yardımcı olun", + "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Bu projenin büyümesine, projeye katkıda bulunarak, hataları raporlayarak veya yeni özellikler önererek yardımcı olabilirsiniz.", + "contribute_on_github": "GitHub'da Katkıda Bulun", + "donate_on_open_collective": "Açık Topluluğa Bağış Yapın", + "browse_anonymously": "Anonim Olarak Göz At" } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index a4586a5e..fe57e617 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -286,5 +286,32 @@ "step_3_steps": "Скопіюйте значення cookie \"sp_dc\"", "step_4_steps": "Вставте скопійоване значення \"sp_dc\"", "friends": "Друзі", - "no_lyrics_available": "Вибачте, не вдалося знайти текст для цього треку" + "no_lyrics_available": "Вибачте, не вдалося знайти текст для цього треку", + "sort_duration": "Сортувати за тривалістю", + "start_a_radio": "Запустити радіо", + "how_to_start_radio": "Як ви хочете запустити радіо?", + "replace_queue_question": "Ви хочете замінити поточну чергу чи додати до неї?", + "endless_playback": "Безкінечне відтворення", + "delete_playlist": "Видалити плейлист", + "delete_playlist_confirmation": "Ви впевнені, що хочете видалити цей плейлист?", + "local_tracks": "Місцеві треки", + "song_link": "Посилання на пісню", + "skip_this_nonsense": "Пропустити цей бред", + "freedom_of_music": "“Свобода музики”", + "freedom_of_music_palm": "“Свобода музики у вашій долоні”", + "get_started": "Давайте почнемо", + "youtube_source_description": "Рекомендовано та працює краще за все.", + "piped_source_description": "Чи почуваєте себе вільно? Те саме, що і на YouTube, але набагато безкоштовно.", + "jiosaavn_source_description": "Найкраще для регіону Південної Азії.", + "highest_quality": "Найвища якість: {quality}", + "select_audio_source": "Виберіть джерело аудіо", + "endless_playback_description": "Автоматично додавати нові пісні\nв кінець черги", + "choose_your_region": "Виберіть ваш регіон", + "choose_your_region_description": "Це допоможе Spotube показати вам правильний контент\nдля вашого місцезнаходження.", + "choose_your_language": "Виберіть свою мову", + "help_project_grow": "Допоможіть цьому проекту рости", + "help_project_grow_description": "Spotube - це проект з відкритим кодом. Ви можете допомогти цьому проекту зростати, вносячи свій внесок у проект, повідомляючи про помилки або пропонуючи нові функції.", + "contribute_on_github": "Долучайтесь на GitHub", + "donate_on_open_collective": "Пожертвуйте на Open Collective", + "browse_anonymously": "Анонімно переглядати" } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index d8d337c2..0e9b0b7c 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -284,5 +284,32 @@ "discord_rich_presence": "Hiển thị trạng thái Discord", "browse_all": "Duyệt tất cả", "genres": "Thể loại", - "explore_genres": "Khám phá Thể loại" -} + "explore_genres": "Khám phá Thể loại", + "sort_duration": "Sắp xếp theo Thời lượng", + "start_a_radio": "Bắt đầu Một Đài phát thanh", + "how_to_start_radio": "Bạn muốn bắt đầu đài phát thanh như thế nào?", + "replace_queue_question": "Bạn muốn thay thế hàng đợi hiện tại hay thêm vào?", + "endless_playback": "Phát không giới hạn", + "delete_playlist": "Xóa Danh sách phát", + "delete_playlist_confirmation": "Bạn có chắc chắn muốn xóa danh sách phát này không?", + "local_tracks": "Bài hát Địa phương", + "song_link": "Liên kết Bài hát", + "skip_this_nonsense": "Bỏ qua bớt rối này", + "freedom_of_music": "“Sự Tự do của Âm nhạc”", + "freedom_of_music_palm": "“Sự Tự do của Âm nhạc trong lòng bàn tay của bạn”", + "get_started": "Bắt đầu thôi", + "youtube_source_description": "Được đề xuất và hoạt động tốt nhất.", + "piped_source_description": "Cảm thấy tự do? Giống như YouTube nhưng miễn phí hơn rất nhiều.", + "jiosaavn_source_description": "Tốt nhất cho khu vực Nam Á.", + "highest_quality": "Chất lượng Tốt nhất: {quality}", + "select_audio_source": "Chọn Nguồn Âm thanh", + "endless_playback_description": "Tự động thêm các bài hát mới\nvào cuối hàng đợi", + "choose_your_region": "Chọn khu vực của bạn", + "choose_your_region_description": "Điều này sẽ giúp Spotube hiển thị nội dung phù hợp cho vị trí của bạn.", + "choose_your_language": "Chọn ngôn ngữ của bạn", + "help_project_grow": "Hãy giúp dự án này phát triển", + "help_project_grow_description": "Spotube là một dự án mã nguồn mở. Bạn có thể giúp dự án này phát triển bằng cách đóng góp vào dự án, báo cáo lỗi hoặc đề xuất tính năng mới.", + "contribute_on_github": "Đóng góp trên GitHub", + "donate_on_open_collective": "Quyên góp trên Open Collective", + "browse_anonymously": "Duyệt Anonymously" +} \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 20fdb329..506661f0 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -286,5 +286,32 @@ "step_3_steps": "复制\"sp_dc\" Cookie的值", "step_4_steps": "粘贴复制的\"sp_dc\"值", "friends": "朋友", - "no_lyrics_available": "抱歉,无法找到此曲的歌词" + "no_lyrics_available": "抱歉,无法找到此曲的歌词", + "sort_duration": "按时长排序", + "start_a_radio": "开始收听电台", + "how_to_start_radio": "您想如何开始收听电台?", + "replace_queue_question": "您想要替换当前队列还是追加到队列?", + "endless_playback": "无尽播放", + "delete_playlist": "删除播放列表", + "delete_playlist_confirmation": "您确定要删除此播放列表吗?", + "local_tracks": "本地音轨", + "song_link": "歌曲链接", + "skip_this_nonsense": "跳过此无聊内容", + "freedom_of_music": "“音乐的自由”", + "freedom_of_music_palm": "“音乐的自由掌握在您手中”", + "get_started": "让我们开始吧", + "youtube_source_description": "推荐并且效果最佳。", + "piped_source_description": "感觉自由?与YouTube一样但更自由。", + "jiosaavn_source_description": "最适合南亚地区。", + "highest_quality": "最高音质:{quality}", + "select_audio_source": "选择音频源", + "endless_playback_description": "自动将新歌曲添加到队列的末尾", + "choose_your_region": "选择您的地区", + "choose_your_region_description": "这将帮助Spotube为您的位置显示正确的内容。", + "choose_your_language": "选择您的语言", + "help_project_grow": "帮助这个项目成长", + "help_project_grow_description": "Spotube是一个开源项目。您可以通过为项目做出贡献、报告错误或建议新功能来帮助该项目成长。", + "contribute_on_github": "在GitHub上做出贡献", + "donate_on_open_collective": "在Open Collective上捐款", + "browse_anonymously": "匿名浏览" } \ No newline at end of file From 389a4fc704fef509c8321502150d5dfdf0295796 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 8 Mar 2024 21:08:38 +0600 Subject: [PATCH 008/261] cd: remove debug step in upload task --- .github/workflows/spotube-release-binary.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index adb99003..14aeafa4 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -435,11 +435,6 @@ jobs: RELEASE.md5sum RELEASE.sha256sum - - name: Debug With SSH - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - name: Upload Release Binaries (stable) if: ${{ !inputs.dry_run && inputs.channel == 'stable' }} uses: ncipollo/release-action@v1 From 4a044498a40c070cb70b050f9a46e5fbb7b6ab95 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 8 Mar 2024 23:03:56 +0600 Subject: [PATCH 009/261] chore: fix macos disable hardened runtime --- macos/Runner.xcodeproj/project.pbxproj | 4 +- macos/Runner/DebugProfile.entitlements | 2 - macos/Runner/RunnerDebug.entitlements | 2 - untranslated_messages.json | 600 +------------------------ 4 files changed, 4 insertions(+), 604 deletions(-) diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 0ee2c9fa..a2dd74c4 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -428,6 +428,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spotube; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; @@ -558,6 +559,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 88NVGSJ5N3; + ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spotube; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; @@ -582,7 +584,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_TEAM = 88NVGSJ5N3; - ENABLE_HARDENED_RUNTIME = YES; + ENABLE_HARDENED_RUNTIME = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Spotube; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music"; diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index e9de2261..f05277de 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -6,8 +6,6 @@ com.apple.security.assets.music.read-write - com.apple.security.cs.allow-jit - com.apple.security.files.downloads.read-write com.apple.security.files.user-selected.read-write diff --git a/macos/Runner/RunnerDebug.entitlements b/macos/Runner/RunnerDebug.entitlements index e9de2261..f05277de 100644 --- a/macos/Runner/RunnerDebug.entitlements +++ b/macos/Runner/RunnerDebug.entitlements @@ -6,8 +6,6 @@ com.apple.security.assets.music.read-write - com.apple.security.cs.allow-jit - com.apple.security.files.downloads.read-write com.apple.security.files.user-selected.read-write diff --git a/untranslated_messages.json b/untranslated_messages.json index a6724f2b..4275f461 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,604 +1,6 @@ { - "ar": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "bn": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "ca": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "de": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "es": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "fa": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "fr": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "hi": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "it": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "ja": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "ko": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "ne": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "nl": [ - "sort_duration", - "audio_source", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "pl": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "pt": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "ru": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "tr": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "uk": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - "vi": [ - "sort_duration", "friends", - "no_lyrics_available", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" - ], - - "zh": [ - "sort_duration", - "start_a_radio", - "how_to_start_radio", - "replace_queue_question", - "endless_playback", - "delete_playlist", - "delete_playlist_confirmation", - "local_tracks", - "song_link", - "skip_this_nonsense", - "freedom_of_music", - "freedom_of_music_palm", - "get_started", - "youtube_source_description", - "piped_source_description", - "jiosaavn_source_description", - "highest_quality", - "select_audio_source", - "endless_playback_description", - "choose_your_region", - "choose_your_region_description", - "choose_your_language", - "help_project_grow", - "help_project_grow_description", - "contribute_on_github", - "donate_on_open_collective", - "browse_anonymously" + "no_lyrics_available" ] } From a248a4b48c1fd5d46e2f3dedb9330efa25de571f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 8 Mar 2024 23:07:55 +0600 Subject: [PATCH 010/261] chore: fix getting started showing up everytime --- lib/main.dart | 1 - lib/pages/getting_started/sections/support.dart | 16 ++++++++++------ lib/services/kv_store/kv_store.dart | 4 ++-- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 31c1da57..01e418dd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -70,7 +70,6 @@ Future main(List rawArgs) async { } await KVStoreService.initialize(); - KVStoreService.doneGettingStarted = false; final hiveCacheDir = kIsWeb ? null : (await getApplicationSupportDirectory()).path; diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index 1be7ca34..46823425 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -101,9 +101,11 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { style: TextButton.styleFrom( foregroundColor: Colors.white, ), - onPressed: () { - KVStoreService.doneGettingStarted = true; - context.go("/"); + onPressed: () async { + await KVStoreService.setDoneGettingStarted(true); + if (context.mounted) { + context.go("/"); + } }, ), ), @@ -115,9 +117,11 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { backgroundColor: const Color(0xff1db954), foregroundColor: Colors.white, ), - onPressed: () { - KVStoreService.doneGettingStarted = true; - context.push("/login"); + onPressed: () async { + await KVStoreService.setDoneGettingStarted(true); + if (context.mounted) { + context.push("/login"); + } }, ), ], diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index 6f6807e0..c1275612 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -10,6 +10,6 @@ abstract class KVStoreService { static bool get doneGettingStarted => sharedPreferences.getBool('doneGettingStarted') ?? false; - static set doneGettingStarted(bool value) => - sharedPreferences.setBool('doneGettingStarted', value); + static Future setDoneGettingStarted(bool value) async => + await sharedPreferences.setBool('doneGettingStarted', value); } From e516afb185f616471822ea745495a3d1d1281bd3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 9 Mar 2024 00:00:36 +0600 Subject: [PATCH 011/261] fix(android): only ask battery optimization once #1252 --- .../use_disable_battery_optimizations.dart | 46 ++++--------------- lib/services/kv_store/kv_store.dart | 5 ++ 2 files changed, 15 insertions(+), 36 deletions(-) diff --git a/lib/hooks/configurators/use_disable_battery_optimizations.dart b/lib/hooks/configurators/use_disable_battery_optimizations.dart index c1155d19..a9afef45 100644 --- a/lib/hooks/configurators/use_disable_battery_optimizations.dart +++ b/lib/hooks/configurators/use_disable_battery_optimizations.dart @@ -1,47 +1,21 @@ import 'package:disable_battery_optimization/disable_battery_optimization.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; -bool _asked = false; void useDisableBatteryOptimizations() { useAsyncEffect(() async { - if (!DesktopTools.platform.isAndroid || _asked) return; - final localStorage = await SharedPreferences.getInstance(); + if (!DesktopTools.platform.isAndroid || + KVStoreService.askedForBatteryOptimization) return; - final rawIsBatteryOptimizationDisabled = - localStorage.getBool("isBatteryOptimizationDisabled"); - final isBatteryOptimizationDisabled = - await DisableBatteryOptimization.isBatteryOptimizationDisabled; - if (rawIsBatteryOptimizationDisabled != false && - isBatteryOptimizationDisabled == false) { - final hasDisabled = await DisableBatteryOptimization - .showDisableBatteryOptimizationSettings(); + await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); - localStorage.setBool( - "isBatteryOptimizationDisabled", - hasDisabled == true, - ); - } + await DisableBatteryOptimization + .showDisableManufacturerBatteryOptimizationSettings( + "Your device has additional battery optimization", + "Follow the steps and disable the optimizations to allow smooth functioning of this app", + ); - final rawIsManBatteryOptimizationDisabled = - localStorage.getBool("isManufacturerBatteryOptimizationDisabled"); - final isManBatteryOptimizationDisabled = await DisableBatteryOptimization - .isManufacturerBatteryOptimizationDisabled; - - if (rawIsManBatteryOptimizationDisabled != false && - isManBatteryOptimizationDisabled == false) { - final hasDisabled = await DisableBatteryOptimization - .showDisableManufacturerBatteryOptimizationSettings( - "Your device has additional battery optimization", - "Follow the steps and disable the optimizations to allow smooth functioning of this app", - ); - - localStorage.setBool( - "isManufacturerBatteryOptimizationDisabled", - hasDisabled == true, - ); - } - _asked = true; + await KVStoreService.setAskedForBatteryOptimization(true); }, null, []); } diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index c1275612..5845b120 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -12,4 +12,9 @@ abstract class KVStoreService { sharedPreferences.getBool('doneGettingStarted') ?? false; static Future setDoneGettingStarted(bool value) async => await sharedPreferences.setBool('doneGettingStarted', value); + + static bool get askedForBatteryOptimization => + sharedPreferences.getBool('askedForBatteryOptimization') ?? false; + static Future setAskedForBatteryOptimization(bool value) async => + await sharedPreferences.setBool('askedForBatteryOptimization', value); } From eec7a9dbc41ab673b392690d3fbda335b097f7f9 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 9 Mar 2024 00:05:47 +0600 Subject: [PATCH 012/261] chore: update changelogs and update credits --- CHANGELOG.md | 1 + README.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02624a7f..ddbd4fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file. See [standa * album images are small in certain places ([ca76a39](https://github.com/krtirtho/spotube/commit/ca76a39910b1a5af91aa7882a0d33c9d71db58a2)) * album, artist page not loading [#1282](https://github.com/krtirtho/spotube/issues/1282) ([a9a1d4c](https://github.com/krtirtho/spotube/commit/a9a1d4c9dc24aaf3181dc4090d1822ebfe755991)) * **android:** audio issue when screen is off and broadcast audio session id ([#1221](https://github.com/krtirtho/spotube/issues/1221) & [#1247](https://github.com/krtirtho/spotube/issues/1247)) ([17105a6](https://github.com/krtirtho/spotube/commit/17105a640bf5107bd5d333b9b4d097c14a3949a2)), closes [KRTirtho/spotube#571](https://github.com/KRTirtho/spotube/issues/571) +* **android:** only ask battery optimization once [#1252](https://github.com/krtirtho/spotube/issues/1252) ([e516afb](https://github.com/krtirtho/spotube/commit/e516afb185f616471822ea745495a3d1d1281bd3)) * **android:** pressing back button in any other tab other than home exits the app ([c3289a0](https://github.com/krtirtho/spotube/commit/c3289a0ba4e7de094a15246677ffcb940504ebde)) * **android:** system back button in player page exits the app ([3294f65](https://github.com/krtirtho/spotube/commit/3294f657fe8a03b18d9be8974968b6508465963d)) * cleanTitle removing feat and ft from words instead of whole words ([8612345](https://github.com/krtirtho/spotube/commit/86123456f2ff577921cf62cffca180427dfe1dd5)) diff --git a/README.md b/README.md index 469d03ac..de00054f 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [Piped](https://piped-docs.kavin.rocks/) - Piped is a privacy friendly alternative YouTube frontend, which is efficient and scalable by design. 1. [YouTube](https://youtube.com/) - YouTube is an American online video-sharing platform headquartered in San Bruno, California. Three former PayPal employees—Chad Hurley, Steve Chen, and Jawed Karim—created the service in February 2005 1. [JioSaavn](https://www.jiosaavn.com) - JioSaavn is an Indian online music streaming service and a digital distributor of Bollywood, English and other regional Indian music across the world. Since it was founded in 2007 as Saavn, the company has acquired rights to over 5 crore (50 million) music tracks in 15 languages +1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content 1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution 1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users 1. [Flatpak](https://flatpak.org) - Flatpak is a utility for software deployment and package management for Linux @@ -242,7 +243,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [flutter_hooks](https://github.com/rrousselGit/flutter_hooks) - A flutter implementation of React hooks. It adds a new kind of widget with enhanced code reuse. 1. [flutter_inappwebview](https://inappwebview.dev/) - A Flutter plugin that allows you to add an inline webview, to use an headless webview, and to open an in-app browser window. 1. [flutter_native_splash](https://pub.dev/packages/flutter_native_splash) - Customize Flutter's default white native splash screen with background color and splash image. Supports dark mode, full screen, and more. -1. [flutter_riverpod](https://riverpod.dev) - A simple way to access state from anywhere in your application while robust and testable. +1. [flutter_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. 1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android. 1. [flutter_svg](https://pub.dev/packages/flutter_svg) - An SVG rendering and widget library for Flutter, which allows painting and displaying Scalable Vector Graphics 1.1 files. 1. [form_validator](https://github.com/TheMisir/form-validator) - Simplest form validation library for flutter's form field widgets @@ -251,7 +252,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [google_fonts](https://pub.dev/packages/google_fonts) - A Flutter package to use fonts from fonts.google.com. Supports HTTP fetching, caching, and asset bundling. 1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256. 1. [hive_flutter](https://github.com/hivedb/hive/tree/master/hive_flutter) - Extension for Hive. Makes it easier to use Hive in Flutter apps. -1. [hooks_riverpod](https://riverpod.dev) - A simple way to access state from anywhere in your application while robust and testable. +1. [hooks_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. 1. [html](https://pub.dev/packages/html) - APIs for parsing and manipulating HTML content outside the browser. 1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests. 1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. @@ -268,14 +269,12 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [path](https://pub.dev/packages/path) - A string-based path manipulation library. All of the path operations you know and love, with solid support for Windows, POSIX (Linux and Mac OS X), and the web. 1. [path_provider](https://pub.dev/packages/path_provider) - Flutter plugin for getting commonly used locations on host platform file systems, such as the temp and app data directories. 1. [permission_handler](https://pub.dev/packages/permission_handler) - Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions. -1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video 1. [popover](https://github.com/minikin/popover) - A popover is a transient view that appears above other content onscreen when you tap a control or in an area. 1. [scroll_to_index](https://github.com/quire-io/scroll-to-index) - Scroll to a specific child of any scrollable widget in Flutter 1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget 1. [shared_preferences](https://pub.dev/packages/shared_preferences) - Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. 1. [skeleton_text](https://github.com/101Loop/Skeleton-Text) - A package that provides an easy way to add skeleton text loading animation in Flutter project. This project is a part of 101Loop community. 1. [smtc_windows](https://github.com/KRTirtho/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet. -1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. 1. [stroke_text](https://github.com/MohamedAbd0/stroke_text) - A Simple Flutter plugin for applying stroke (border) style to a text widget 1. [system_theme](https://pub.dev/packages/system_theme) - A plugin to get the current system theme info. Supports Android, Web, Windows, Linux and macOS 1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos. @@ -298,6 +297,9 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [app_links](https://github.com/llfbandit/app_links) - Android App Links, Deep Links, iOs Universal Links and Custom URL schemes handler for Flutter (desktop included). 1. [win32_registry](https://win32.pub) - A package that provides a friendly Dart API for accessing the Windows Registry. 1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. +1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. +1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. +1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. 1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. 1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. 1. [flutter_distributor](https://distributor.leanflutter.org) - A complete tool for packaging and publishing your Flutter apps. @@ -308,7 +310,9 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. 1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. 1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. +1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. 1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development +1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video 1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. 1. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter. 1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item. From 653900962937da58d00c6cbec8a6157e7a6964fe Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 9 Mar 2024 00:38:08 +0600 Subject: [PATCH 013/261] cd: fix linux tar --- .github/workflows/spotube-release-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 14aeafa4..68ea2d67 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -181,7 +181,7 @@ jobs: - uses: actions/upload-artifact@v3 - if: ${{ inputs.channel == 'release' }} + if: ${{ inputs.channel == 'stable' }} with: if-no-files-found: error name: Spotube-Release-Binaries From ca2b81d5720c79fcaaa545c990f8770243f0bf6a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 9 Mar 2024 22:28:40 +0600 Subject: [PATCH 014/261] chore: fix linux appdata formatting --- linux/com.github.KRTirtho.Spotube.appdata.xml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/linux/com.github.KRTirtho.Spotube.appdata.xml b/linux/com.github.KRTirtho.Spotube.appdata.xml index 7b6c92c2..ebe2fb7d 100644 --- a/linux/com.github.KRTirtho.Spotube.appdata.xml +++ b/linux/com.github.KRTirtho.Spotube.appdata.xml @@ -3,7 +3,7 @@ com.github.KRTirtho.Spotube Spotube - 🎧 Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! + Freedom of music CC0-1.0 BSD-4-Clause @@ -13,9 +13,11 @@ touch Kingkor Roy Tirtho - https://github.com/krtirtho/spotube + https://github.com/krtirtho/spotube/issues + https://spotube.krtirtho.dev + https://opencollective.com/spotube -

🎧 Open source Spotify client that doesn't require Premium nor uses Electron! Available for +

Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile!

Following are the features that currently spotube offers:

    @@ -30,12 +32,13 @@
  • 📖 Open source/libre software
  • 🔉 Playback control is done locally, not on the server
-
- + - https://rawcdn.githack.com/KRTirtho/spotube/62055018feade0b895663a0bfc5f85f265ae2154/assets/spotube-screenshot.png + https://rawcdn.githack.com/KRTirtho/spotube/62055018feade0b895663a0bfc5f85f265ae2154/assets/spotube-screenshot.png + + Spotube screenshot com.github.KRTirtho.Spotube.desktop From f37ac06e1a0b9e0f8a4f309f110224d38422b82b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 13 Mar 2024 14:30:11 +0600 Subject: [PATCH 015/261] chore: unnecessary test files --- server/.pocketbase | 1 - .../1675256468_created_tracks.js | 63 ------------------- .../1675256557_updated_tracks.js | 17 ----- .../pb_migrations/1675256593_updated_users.js | 19 ------ .../1675256678_updated_tracks.js | 17 ----- .../1675257121_updated_tracks.js | 17 ----- .../1675257148_updated_tracks.js | 39 ------------ 7 files changed, 173 deletions(-) delete mode 100644 server/.pocketbase delete mode 100644 server/pb_migrations/1675256468_created_tracks.js delete mode 100644 server/pb_migrations/1675256557_updated_tracks.js delete mode 100644 server/pb_migrations/1675256593_updated_users.js delete mode 100644 server/pb_migrations/1675256678_updated_tracks.js delete mode 100644 server/pb_migrations/1675257121_updated_tracks.js delete mode 100644 server/pb_migrations/1675257148_updated_tracks.js diff --git a/server/.pocketbase b/server/.pocketbase deleted file mode 100644 index bcb8312e..00000000 --- a/server/.pocketbase +++ /dev/null @@ -1 +0,0 @@ -version=0.12.1 \ No newline at end of file diff --git a/server/pb_migrations/1675256468_created_tracks.js b/server/pb_migrations/1675256468_created_tracks.js deleted file mode 100644 index 46d03fbb..00000000 --- a/server/pb_migrations/1675256468_created_tracks.js +++ /dev/null @@ -1,63 +0,0 @@ -migrate((db) => { - const collection = new Collection({ - "id": "pevn93oxbnovw0s", - "created": "2023-02-01 13:01:08.893Z", - "updated": "2023-02-01 13:01:08.893Z", - "name": "tracks", - "type": "base", - "system": false, - "schema": [ - { - "system": false, - "id": "ycnix0ai", - "name": "spotify_id", - "type": "text", - "required": true, - "unique": false, - "options": { - "min": 20, - "max": 22, - "pattern": "" - } - }, - { - "system": false, - "id": "ih8fxzgh", - "name": "youtube_id", - "type": "text", - "required": true, - "unique": false, - "options": { - "min": 10, - "max": 11, - "pattern": "" - } - }, - { - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": true, - "unique": false, - "options": { - "min": null, - "max": null - } - } - ], - "listRule": null, - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "options": {} - }); - - return Dao(db).saveCollection(collection); -}, (db) => { - const dao = new Dao(db); - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s"); - - return dao.deleteCollection(collection); -}) diff --git a/server/pb_migrations/1675256557_updated_tracks.js b/server/pb_migrations/1675256557_updated_tracks.js deleted file mode 100644 index cdcf19bc..00000000 --- a/server/pb_migrations/1675256557_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.listRule = "" - collection.viewRule = "" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.listRule = null - collection.viewRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675256593_updated_users.js b/server/pb_migrations/1675256593_updated_users.js deleted file mode 100644 index 5643c3a0..00000000 --- a/server/pb_migrations/1675256593_updated_users.js +++ /dev/null @@ -1,19 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("_pb_users_auth_") - - collection.createRule = null - collection.updateRule = null - collection.deleteRule = null - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("_pb_users_auth_") - - collection.createRule = "" - collection.updateRule = "id = @request.auth.id" - collection.deleteRule = "id = @request.auth.id" - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675256678_updated_tracks.js b/server/pb_migrations/1675256678_updated_tracks.js deleted file mode 100644 index 4b472ad1..00000000 --- a/server/pb_migrations/1675256678_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = "@request.auth.id != ''" - collection.updateRule = "@request.auth.id != ''" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = null - collection.updateRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675257121_updated_tracks.js b/server/pb_migrations/1675257121_updated_tracks.js deleted file mode 100644 index a1b7604f..00000000 --- a/server/pb_migrations/1675257121_updated_tracks.js +++ /dev/null @@ -1,17 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = "@request.auth.id != '' && ((spotify_id ?= @collection.tracks.spotify_id && youtube_id ?= @collection.tracks.youtube_id) || (spotify_id ?!= @collection.tracks.spotify_id && youtube_id ?!= @collection.tracks.youtube_id))" - collection.updateRule = "@request.auth.id != '' && ((spotify_id ?= @collection.tracks.spotify_id && youtube_id ?= @collection.tracks.youtube_id) || (spotify_id ?!= @collection.tracks.spotify_id && youtube_id ?!= @collection.tracks.youtube_id))" - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - collection.createRule = null - collection.updateRule = null - - return dao.saveCollection(collection) -}) diff --git a/server/pb_migrations/1675257148_updated_tracks.js b/server/pb_migrations/1675257148_updated_tracks.js deleted file mode 100644 index 544d0e85..00000000 --- a/server/pb_migrations/1675257148_updated_tracks.js +++ /dev/null @@ -1,39 +0,0 @@ -migrate((db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - // update - collection.schema.addField(new SchemaField({ - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": false, - "unique": false, - "options": { - "min": null, - "max": null - } - })) - - return dao.saveCollection(collection) -}, (db) => { - const dao = new Dao(db) - const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s") - - // update - collection.schema.addField(new SchemaField({ - "system": false, - "id": "vzvqgsjf", - "name": "votes", - "type": "number", - "required": true, - "unique": false, - "options": { - "min": null, - "max": null - } - })) - - return dao.saveCollection(collection) -}) From 35e9920b516440ece0f2ed47f747ce7874b9ee2a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 13 Mar 2024 14:34:51 +0600 Subject: [PATCH 016/261] chore: add riverpod lint --- analysis_options.yaml | 2 ++ lib/main.dart | 29 ++++++++++---------- pubspec.lock | 64 +++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 ++ 4 files changed, 83 insertions(+), 14 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 5f2cbbe1..748fc015 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -34,3 +34,5 @@ analyzer: - patterns errors: invalid_annotation_target: ignore + plugins: + - custom_lint diff --git a/lib/main.dart b/lib/main.dart index 01e418dd..3281f85f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart'; @@ -135,21 +136,21 @@ Future main(List rawArgs) async { ), runAppFunction: () { runApp( - DevicePreview( - availableLocales: L10n.all, - enabled: false, - data: const DevicePreviewData( - isEnabled: false, - orientation: Orientation.portrait, - ), - builder: (context) { - return ProviderScope( - child: QueryClientProvider( + ProviderScope( + child: DevicePreview( + availableLocales: L10n.all, + enabled: false, + data: const DevicePreviewData( + isEnabled: false, + orientation: Orientation.portrait, + ), + builder: (context) { + return QueryClientProvider( staleDuration: const Duration(minutes: 30), child: const Spotube(), - ), - ); - }, + ); + }, + ), ), ); }, @@ -157,7 +158,7 @@ Future main(List rawArgs) async { } class Spotube extends StatefulHookConsumerWidget { - const Spotube({Key? key}) : super(key: key); + const Spotube({super.key}); @override SpotubeState createState() => SpotubeState(); diff --git a/pubspec.lock b/pubspec.lock index cc69663d..4485b118 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.13.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d + url: "https://pub.dev" + source: hosted + version: "0.11.2" ansicolor: dependency: transitive description: @@ -313,6 +321,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" cli_util: dependency: transitive description: @@ -401,6 +417,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.3" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "22bd87a362f433ba6aae127a7bac2838645270737f3721b180916d7c5946cb5d" + url: "https://pub.dev" + source: hosted + version: "0.5.11" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: "0d48e002438950f9582e574ef806b2bea5719d8d14c0f9f754fbad729bcf3b19" + url: "https://pub.dev" + source: hosted + version: "0.5.14" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "2952837953022de610dacb464f045594854ced6506ac7f76af28d4a6490e189b" + url: "https://pub.dev" + source: hosted + version: "0.5.14" dart_des: dependency: transitive description: @@ -1098,6 +1138,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.10" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e + url: "https://pub.dev" + source: hosted + version: "4.2.0" html: dependency: "direct main" description: @@ -1752,6 +1800,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: d72d7096964baf288b55619fe48100001fc4564ab7923ed0a7f5c7650e03c0d6 + url: "https://pub.dev" + source: hosted + version: "0.3.4" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "70198738c3047ae4f6517ef1a2011a8514a980a52576c7f629a3a08810319a02" + url: "https://pub.dev" + source: hosted + version: "2.1.1" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 04d3f1a4..e055c9d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -143,6 +143,8 @@ dev_dependencies: pub_api_client: ^2.4.0 pubspec_parse: ^1.2.2 freezed: ^2.4.6 + custom_lint: ^0.5.11 + riverpod_lint: ^2.1.1 dependency_overrides: system_tray: 2.0.2 From f4dce2f6116e57458803d4291560156c52f74b5f Mon Sep 17 00:00:00 2001 From: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Date: Wed, 20 Mar 2024 16:20:09 +0100 Subject: [PATCH 017/261] docs: broken link in README.md (fixes #1310) (#1311) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index de00054f..4ad4e1be 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ eliminating the need for Spotify Premium Btw it's not just another Electron app 😉 -Visit the website +Visit the website Discord Server Support me on Patron From 6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 20 Mar 2024 23:38:39 +0600 Subject: [PATCH 018/261] feat: improved caching based on riverpod (#1343) * feat: add riverpod based favorite album provider * feat: add album is saved, new releases and tracks providers * feat: add artist related providers * feat: add all categories providers * feat: add lyrics provider * feat: add playlist related providers * feat: add search provider * feat: add view and spotify friends provider * feat: add playlist create and update and favorite handlers * feat: use providers in home screen * chore: fix dart lint issues * feat: use new providers for playlist and albums screen * feat: use providers in artist page * feat: use providers on library page * feat: use provider for playlist and album card and heart button * feat: use provider in search page * feat: use providers in generate playlist * feat: use provider in lyrics screen * feat: use provider for create playlist * feat: use provider in add track dialog * feat: use providers in remaining pages and remove fl_query * fix: remove direct access to provider.value * fix: glitching when loading * fix: user album loading next page indicator * feat: make many provider autoDispose after 5 minutes of no usage * fix: ignore episodes in tracks --- .vscode/settings.json | 1 + .vscode/snippets.code-snippets | 170 ++++ analysis_options.yaml | 1 + lib/collections/routes.dart | 4 +- lib/components/album/album_card.dart | 27 +- lib/components/artist/artist_album_list.dart | 21 +- lib/components/artist/artist_card.dart | 2 +- lib/components/desktop_login/login_form.dart | 4 +- lib/components/home/sections/featured.dart | 27 +- lib/components/home/sections/friends.dart | 14 +- .../home/sections/friends/friend_item.dart | 28 +- lib/components/home/sections/genres.dart | 24 +- .../home/sections/made_for_user.dart | 10 +- .../home/sections/new_releases.dart | 45 +- .../playlist_generate/multi_select_field.dart | 8 +- .../recommendation_attribute_dials.dart | 4 +- .../recommendation_attribute_fields.dart | 4 +- .../seeds_multi_autocomplete.dart | 4 +- .../playlist_generate/simple_track_tile.dart | 4 +- lib/components/library/user_albums.dart | 60 +- lib/components/library/user_artists.dart | 19 +- lib/components/library/user_downloads.dart | 2 +- .../library/user_downloads/download_item.dart | 4 +- lib/components/library/user_local_tracks.dart | 8 +- lib/components/library/user_playlists.dart | 28 +- lib/components/lyrics/zoom_controls.dart | 4 +- lib/components/player/player.dart | 4 +- lib/components/player/player_actions.dart | 6 +- lib/components/player/player_controls.dart | 6 +- lib/components/player/player_overlay.dart | 4 +- lib/components/player/player_queue.dart | 4 +- .../player/player_track_details.dart | 3 +- .../player/sibling_tracks_sheet.dart | 4 +- lib/components/player/volume_slider.dart | 4 +- lib/components/playlist/playlist_card.dart | 42 +- .../playlist/playlist_create_dialog.dart | 53 +- lib/components/root/bottom_player.dart | 2 +- lib/components/root/sidebar.dart | 16 +- .../root/spotube_navigation_bar.dart | 4 +- .../settings/color_scheme_picker_dialog.dart | 10 +- .../adaptive/adaptive_popup_menu_button.dart | 4 +- lib/components/shared/animated_gradient.dart | 5 +- lib/components/shared/compact_search.dart | 4 +- .../dialogs/confirm_download_dialog.dart | 4 +- .../shared/dialogs/piped_down_dialog.dart | 2 +- .../dialogs/playlist_add_track_dialog.dart | 48 +- .../dialogs/replace_downloaded_dialog.dart | 3 +- .../shared/dialogs/track_details_dialog.dart | 4 +- .../expandable_search/expandable_search.dart | 8 +- .../shared/fallbacks/anonymous_fallback.dart | 4 +- .../shared/fallbacks/not_found.dart | 2 +- lib/components/shared/heart_button.dart | 186 +---- .../horizontal_playbutton_card_view.dart | 15 +- lib/components/shared/hover_builder.dart | 4 +- .../shared/image/universal_image.dart | 4 +- .../shared/links/anchor_button.dart | 4 +- lib/components/shared/links/hyper_link.dart | 4 +- lib/components/shared/links/link_text.dart | 4 +- .../shared/page_window_title_bar.dart | 67 +- lib/components/shared/panels/helpers.dart | 3 +- .../shared/panels/sliding_up_panel.dart | 5 +- lib/components/shared/playbutton_card.dart | 4 +- .../shared/shimmers/shimmer_lyrics.dart | 2 +- .../shared/sort_tracks_dropdown.dart | 4 +- .../shared/themed_button_tab_bar.dart | 2 +- .../shared/track_tile/track_options.dart | 48 +- .../shared/track_tile/track_tile.dart | 4 +- .../sections/body/track_view_body.dart | 2 +- .../body/track_view_body_headers.dart | 4 +- .../sections/body/track_view_options.dart | 2 +- .../sections/body/use_is_user_playlist.dart | 14 +- .../sections/header/flexible_header.dart | 2 +- .../sections/header/header_actions.dart | 2 +- .../sections/header/header_buttons.dart | 4 +- .../shared/tracks_view/track_view.dart | 2 +- .../shared/tracks_view/track_view_props.dart | 14 - lib/components/shared/waypoint.dart | 4 +- lib/extensions/infinite_query.dart | 34 - lib/hooks/configurators/use_deep_linking.dart | 26 +- .../configurators/use_endless_playback.dart | 16 +- .../use_auto_scroll_controller.dart | 4 +- lib/hooks/controllers/use_package_info.dart | 4 +- .../controllers/use_sidebarx_controller.dart | 4 +- .../spotify/use_spotify_infinite_query.dart | 53 -- lib/hooks/spotify/use_spotify_mutation.dart | 36 - lib/hooks/spotify/use_spotify_query.dart | 52 -- lib/l10n/l10n.dart | 1 + lib/main.dart | 14 +- lib/models/spotify/recommendation_seeds.dart | 40 + .../spotify/recommendation_seeds.freezed.dart | 756 ++++++++++++++++++ .../spotify/recommendation_seeds.g.dart | 45 ++ lib/pages/album/album.dart | 71 +- lib/pages/artist/artist.dart | 12 +- lib/pages/artist/section/footer.dart | 21 +- lib/pages/artist/section/header.dart | 90 +-- lib/pages/artist/section/related_artists.dart | 64 +- lib/pages/artist/section/top_tracks.dart | 14 +- lib/pages/desktop_login/desktop_login.dart | 2 +- lib/pages/desktop_login/login_tutorial.dart | 2 +- lib/pages/home/genres/genre_playlists.dart | 39 +- lib/pages/home/genres/genres.dart | 17 +- lib/pages/home/home.dart | 2 +- lib/pages/lastfm_login/lastfm_login.dart | 2 +- lib/pages/library/library.dart | 2 +- .../playlist_generate/playlist_generate.dart | 295 +++++-- .../playlist_generate_result.dart | 394 +++++---- lib/pages/lyrics/lyrics.dart | 2 +- lib/pages/lyrics/mini_lyrics.dart | 2 +- lib/pages/lyrics/plain_lyrics.dart | 15 +- lib/pages/lyrics/synced_lyrics.dart | 41 +- lib/pages/mobile_login/mobile_login.dart | 2 +- lib/pages/playlist/liked_playlist.dart | 12 +- lib/pages/playlist/playlist.dart | 102 +-- lib/pages/root/root_app.dart | 11 +- lib/pages/search/search.dart | 86 +- lib/pages/search/sections/albums.dart | 30 +- lib/pages/search/sections/artists.dart | 27 +- lib/pages/search/sections/playlists.dart | 29 +- lib/pages/search/sections/tracks.dart | 38 +- lib/pages/settings/about.dart | 2 +- lib/pages/settings/blacklist.dart | 2 +- lib/pages/settings/logs.dart | 2 +- lib/pages/settings/sections/about.dart | 2 +- lib/pages/settings/sections/accounts.dart | 2 +- lib/pages/settings/sections/appearance.dart | 4 +- lib/pages/settings/sections/desktop.dart | 2 +- lib/pages/settings/sections/developers.dart | 2 +- lib/pages/settings/sections/downloads.dart | 2 +- lib/pages/settings/sections/playback.dart | 2 +- lib/pages/settings/settings.dart | 2 +- lib/pages/track/track.dart | 10 +- lib/provider/authentication_provider.dart | 4 +- lib/provider/blacklist_provider.dart | 2 +- .../custom_spotify_endpoint_provider.dart | 2 + lib/provider/spotify/album/favorite.dart | 86 ++ lib/provider/spotify/album/is_saved.dart | 10 + lib/provider/spotify/album/releases.dart | 90 +++ lib/provider/spotify/album/tracks.dart | 58 ++ lib/provider/spotify/artist/albums.dart | 62 ++ lib/provider/spotify/artist/artist.dart | 10 + lib/provider/spotify/artist/following.dart | 104 +++ lib/provider/spotify/artist/is_following.dart | 10 + lib/provider/spotify/artist/related.dart | 11 + lib/provider/spotify/artist/top_tracks.dart | 15 + lib/provider/spotify/artist/wikipedia.dart | 12 + lib/provider/spotify/category/categories.dart | 20 + lib/provider/spotify/category/genres.dart | 6 + lib/provider/spotify/category/playlists.dart | 67 ++ lib/provider/spotify/lyrics/synced.dart | 77 ++ lib/provider/spotify/playlist/favorite.dart | 122 +++ lib/provider/spotify/playlist/featured.dart | 58 ++ lib/provider/spotify/playlist/generate.dart | 40 + lib/provider/spotify/playlist/liked.dart | 49 ++ lib/provider/spotify/playlist/playlist.dart | 90 +++ lib/provider/spotify/playlist/tracks.dart | 64 ++ lib/provider/spotify/search/search.dart | 76 ++ lib/provider/spotify/spotify.dart | 73 ++ lib/provider/spotify/tracks/track.dart | 10 + lib/provider/spotify/user/friends.dart | 7 + lib/provider/spotify/user/me.dart | 6 + lib/provider/spotify/utils/async.dart | 5 + lib/provider/spotify/utils/mixin.dart | 24 + lib/provider/spotify/utils/persistence.dart | 40 + lib/provider/spotify/utils/provider.dart | 6 + .../spotify/utils/provider/cursor.dart | 56 ++ .../spotify/utils/provider/cursor_family.dart | 113 +++ .../spotify/utils/provider/paginated.dart | 63 ++ .../utils/provider/paginated_family.dart | 113 +++ lib/provider/spotify/utils/state.dart | 56 ++ lib/provider/spotify/views/view.dart | 19 + .../audio_services/linux_audio_service.dart | 2 +- .../audio_services/mobile_audio_service.dart | 2 +- lib/services/connectivity_adapter.dart | 17 +- .../download_manager/download_manager.dart | 35 +- .../download_manager/download_task.dart | 7 +- lib/services/mutations/album.dart | 31 - lib/services/mutations/mutations.dart | 12 - lib/services/mutations/playlist.dart | 147 ---- lib/services/mutations/track.dart | 32 - lib/services/queries/album.dart | 114 --- lib/services/queries/artist.dart | 151 ---- lib/services/queries/category.dart | 120 --- lib/services/queries/lyrics.dart | 114 --- lib/services/queries/playlist.dart | 318 -------- lib/services/queries/queries.dart | 24 - lib/services/queries/search.dart | 60 -- lib/services/queries/tracks.dart | 16 - lib/services/queries/user.dart | 53 -- lib/services/queries/views.dart | 47 -- lib/utils/persisted_state_notifier.dart | 2 +- lib/utils/type_conversion_utils.dart | 3 + pubspec.lock | 40 - pubspec.yaml | 3 - 193 files changed, 3862 insertions(+), 2954 deletions(-) create mode 100644 .vscode/snippets.code-snippets delete mode 100644 lib/extensions/infinite_query.dart delete mode 100644 lib/hooks/spotify/use_spotify_infinite_query.dart delete mode 100644 lib/hooks/spotify/use_spotify_mutation.dart delete mode 100644 lib/hooks/spotify/use_spotify_query.dart create mode 100644 lib/models/spotify/recommendation_seeds.dart create mode 100644 lib/models/spotify/recommendation_seeds.freezed.dart create mode 100644 lib/models/spotify/recommendation_seeds.g.dart create mode 100644 lib/provider/spotify/album/favorite.dart create mode 100644 lib/provider/spotify/album/is_saved.dart create mode 100644 lib/provider/spotify/album/releases.dart create mode 100644 lib/provider/spotify/album/tracks.dart create mode 100644 lib/provider/spotify/artist/albums.dart create mode 100644 lib/provider/spotify/artist/artist.dart create mode 100644 lib/provider/spotify/artist/following.dart create mode 100644 lib/provider/spotify/artist/is_following.dart create mode 100644 lib/provider/spotify/artist/related.dart create mode 100644 lib/provider/spotify/artist/top_tracks.dart create mode 100644 lib/provider/spotify/artist/wikipedia.dart create mode 100644 lib/provider/spotify/category/categories.dart create mode 100644 lib/provider/spotify/category/genres.dart create mode 100644 lib/provider/spotify/category/playlists.dart create mode 100644 lib/provider/spotify/lyrics/synced.dart create mode 100644 lib/provider/spotify/playlist/favorite.dart create mode 100644 lib/provider/spotify/playlist/featured.dart create mode 100644 lib/provider/spotify/playlist/generate.dart create mode 100644 lib/provider/spotify/playlist/liked.dart create mode 100644 lib/provider/spotify/playlist/playlist.dart create mode 100644 lib/provider/spotify/playlist/tracks.dart create mode 100644 lib/provider/spotify/search/search.dart create mode 100644 lib/provider/spotify/spotify.dart create mode 100644 lib/provider/spotify/tracks/track.dart create mode 100644 lib/provider/spotify/user/friends.dart create mode 100644 lib/provider/spotify/user/me.dart create mode 100644 lib/provider/spotify/utils/async.dart create mode 100644 lib/provider/spotify/utils/mixin.dart create mode 100644 lib/provider/spotify/utils/persistence.dart create mode 100644 lib/provider/spotify/utils/provider.dart create mode 100644 lib/provider/spotify/utils/provider/cursor.dart create mode 100644 lib/provider/spotify/utils/provider/cursor_family.dart create mode 100644 lib/provider/spotify/utils/provider/paginated.dart create mode 100644 lib/provider/spotify/utils/provider/paginated_family.dart create mode 100644 lib/provider/spotify/utils/state.dart create mode 100644 lib/provider/spotify/views/view.dart delete mode 100644 lib/services/mutations/album.dart delete mode 100644 lib/services/mutations/mutations.dart delete mode 100644 lib/services/mutations/playlist.dart delete mode 100644 lib/services/mutations/track.dart delete mode 100644 lib/services/queries/album.dart delete mode 100644 lib/services/queries/artist.dart delete mode 100644 lib/services/queries/category.dart delete mode 100644 lib/services/queries/lyrics.dart delete mode 100644 lib/services/queries/playlist.dart delete mode 100644 lib/services/queries/queries.dart delete mode 100644 lib/services/queries/search.dart delete mode 100644 lib/services/queries/tracks.dart delete mode 100644 lib/services/queries/user.dart delete mode 100644 lib/services/queries/views.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 0e6a4294..472520ab 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "Buildless", "danceability", "instrumentalness", "Mpris", diff --git a/.vscode/snippets.code-snippets b/.vscode/snippets.code-snippets new file mode 100644 index 00000000..9a18929b --- /dev/null +++ b/.vscode/snippets.code-snippets @@ -0,0 +1,170 @@ +{ + "PaginatedState": { + "scope": "dart", + "prefix": "paginatedState", + "description": "Generate a PaginatedState", + "body": [ + "class ${1:Model}State extends PaginatedState<${2:Model}> {", + " ${1:Model}State({", + " required super.items,", + " required super.offset,", + " required super.limit,", + " required super.hasMore,", + " });", + " ", + " @override", + " ${1:Model}State copyWith({", + " List<${2:Model}>? items,", + " int? offset,", + " int? limit,", + " bool? hasMore,", + " }) {", + " return ${1:Model}State(", + " items: items ?? this.items,", + " offset: offset ?? this.offset,", + " limit: limit ?? this.limit,", + " hasMore: hasMore ?? this.hasMore,", + " );", + " }", + "}" + ] + }, + "PaginatedAsyncNotifier": { + "scope": "dart", + "prefix": "paginatedAsyncNotifier", + "description": "Generate a PaginatedAsyncNotifier", + "body": [ + "class ${1:NotifierName}Notifier extends PaginatedAsyncNotifier<${3:Item}, ${2:Model}State> {", + " ${1:NotifierName}Notifier() : super();", + " ", + " @override", + " fetch(int offset, int limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build() async {", + " throw UnimplementedError();", + " }", + "}" + ] + }, + "PaginaitedNotifierWithState": { + "scope": "dart", + "prefix": "paginatedNotifierWithState", + "description": "Generate a PaginatedNotifier with PaginatedState", + "body": [ + "class $1State extends PaginatedState<$2> {", + " $1State({", + " required super.items,", + " required super.offset,", + " required super.limit,", + " required super.hasMore,", + " });", + " ", + " @override", + " $1State copyWith({", + " List<$2>? items,", + " int? offset,", + " int? limit,", + " bool? hasMore,", + " }) {", + " return $1State(", + " items: items ?? this.items,", + " offset: offset ?? this.offset,", + " limit: limit ?? this.limit,", + " hasMore: hasMore ?? this.hasMore,", + " );", + " }", + "}", + " ", + "class $1Notifier", + " extends PaginatedAsyncNotifier<$2, $1State> {", + " $1Notifier() : super();", + " ", + " @override", + " fetch(int offset, int limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build() async {", + " throw UnimplementedError();", + " }", + "}", + " ", + "final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProvider<$1Notifier, $1State>(", + " ()=> $1Notifier(),", + ");" + ] + }, + "FamilyPaginatedAsyncNotifier": { + "scope": "dart", + "prefix": "familyPaginatedAsyncNotifier", + "description": "Generate a FamilyPaginatedAsyncNotifier", + "body": [ + "class ${1:NotifierName}Notifier extends FamilyPaginatedAsyncNotifier<${3:Item}, ${2:Model}State, {$4:Arg}> {", + " ${1:NotifierName}Notifier() : super();", + " ", + " @override", + " fetch(arg, offset, limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build(arg) async {", + " throw UnimplementedError();", + " }", + "}" + ] + }, + "FamilyPaginaitedNotifierWithState": { + "scope": "dart", + "prefix": "familyPaginatedNotifierWithState", + "description": "Generate a FamilyPaginatedAsyncNotifier with PaginatedState", + "body": [ + "class $1State extends PaginatedState<$2> {", + " $1State({", + " required super.items,", + " required super.offset,", + " required super.limit,", + " required super.hasMore,", + " });", + " ", + " @override", + " $1State copyWith({", + " List<$2>? items,", + " int? offset,", + " int? limit,", + " bool? hasMore,", + " }) {", + " return $1State(", + " items: items ?? this.items,", + " offset: offset ?? this.offset,", + " limit: limit ?? this.limit,", + " hasMore: hasMore ?? this.hasMore,", + " );", + " }", + "}", + " ", + "class $1Notifier", + " extends FamilyPaginatedAsyncNotifier<$2, $1State, $3> {", + " $1Notifier() : super();", + " ", + " @override", + " fetch(arg, offset, limit) async {", + " throw UnimplementedError();", + " }", + " ", + " @override", + " build(arg) async {", + " throw UnimplementedError();", + " }", + "}", + " ", + "final ${1/(.*)/${1:/camelcase}/}Provider = AsyncNotifierProviderFamily<$1Notifier, $1State, $3>(", + " ()=> $1Notifier(),", + ");" + ] + }, +} \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index 748fc015..4ba476e0 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -25,6 +25,7 @@ linter: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule file_names: false + avoid_renaming_method_parameters: false # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 43d0cf2e..8428aaf3 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart' hide Search; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/getting_started/getting_started.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; @@ -96,8 +97,7 @@ final routerProvider = Provider((ref) { path: "result", pageBuilder: (context, state) => SpotubePage( child: PlaylistGenerateResultPage( - state: - state.extra as PlaylistGenerateResultRouteState, + state: state.extra as GeneratePlaylistProviderInput, ), ), ), diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 4d2e12d6..3838b7a4 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -1,15 +1,12 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/infinite_query.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/album.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -31,15 +28,12 @@ class AlbumCard extends HookConsumerWidget { useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final queryClient = useQueryClient(); - bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), [playlist, album.id], ); final updating = useState(false); - final spotify = ref.watch(spotifyProvider); final scaffoldMessenger = ScaffoldMessenger.maybeOf(context); @@ -50,23 +44,8 @@ class AlbumCard extends HookConsumerWidget { TypeConversionUtils.simpleTrack_X_Track(track, album)) .toList(); } - final job = AlbumQueries.tracksOfJob(album.id!); - - final query = queryClient.createInfiniteQuery( - job.queryKey, - (page) => job.task(page, (spotify: spotify, album: album)), - initialPage: 0, - nextPage: job.nextPage, - ); - - return await query.fetchAllTracks( - getAllTracks: () async { - final res = await spotify.albums.tracks(album.id!).all(); - return res - .map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album)) - .toList(); - }, - ); + await ref.read(albumTracksProvider(album).future); + return ref.read(albumTracksProvider(album).notifier).fetchAll(); } return PlaybuttonCard( diff --git a/lib/components/artist/artist_album_list.dart b/lib/components/artist/artist_album_list.dart index 5114170c..a91327ce 100644 --- a/lib/components/artist/artist_album_list.dart +++ b/lib/components/artist/artist_album_list.dart @@ -1,38 +1,35 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class ArtistAlbumList extends HookConsumerWidget { final String artistId; ArtistAlbumList( this.artistId, { - Key? key, - }) : super(key: key); + super.key, + }); final logger = getLogger(ArtistAlbumList); @override Widget build(BuildContext context, ref) { - final albumsQuery = useQueries.artist.albumsOf(ref, artistId); + final albumsQuery = ref.watch(artistAlbumsProvider(artistId)); + final albumsQueryNotifier = + ref.watch(artistAlbumsProvider(artistId).notifier); - final albums = useMemoized(() { - return albumsQuery.pages - .expand((page) => page.items ?? const Iterable.empty()) - .toList(); - }, [albumsQuery.pages]); + final albums = albumsQuery.asData?.value.items ?? []; final theme = Theme.of(context); return HorizontalPlaybuttonCardView( isLoadingNextPage: albumsQuery.isLoadingNextPage, - hasNextPage: albumsQuery.hasNextPage, + hasNextPage: albumsQuery.asData?.value.hasMore ?? false, items: albums, - onFetchMore: albumsQuery.fetchNext, + onFetchMore: albumsQueryNotifier.fetchMore, title: Text( context.l10n.albums, style: theme.textTheme.headlineSmall, diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 3526e88f..ac3e9bec 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -14,7 +14,7 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistCard extends HookConsumerWidget { final Artist artist; - const ArtistCard(this.artist, {Key? key}) : super(key: key); + const ArtistCard(this.artist, {super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart index 5abb9524..a3deb54a 100644 --- a/lib/components/desktop_login/login_form.dart +++ b/lib/components/desktop_login/login_form.dart @@ -8,9 +8,9 @@ import 'package:spotube/provider/authentication_provider.dart'; class TokenLoginForm extends HookConsumerWidget { final void Function()? onDone; const TokenLoginForm({ - Key? key, + super.key, this.onDone, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/home/sections/featured.dart b/lib/components/home/sections/featured.dart index 8a7c2c95..0db5a1e8 100644 --- a/lib/components/home/sections/featured.dart +++ b/lib/components/home/sections/featured.dart @@ -1,35 +1,28 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeFeaturedSection extends HookConsumerWidget { - const HomeFeaturedSection({Key? key}) : super(key: key); + const HomeFeaturedSection({super.key}); @override Widget build(BuildContext context, ref) { - final featuredPlaylistsQuery = useQueries.playlist.featured(ref); - final playlists = useMemoized( - () => featuredPlaylistsQuery.pages - .whereType>() - .expand((page) => page.items ?? const []), - [featuredPlaylistsQuery.pages], - ); - final isLoadingFeaturedPlaylists = !featuredPlaylistsQuery.hasPageData && - !featuredPlaylistsQuery.isLoadingNextPage; + final featuredPlaylists = ref.watch(featuredPlaylistsProvider); + final featuredPlaylistsNotifier = + ref.watch(featuredPlaylistsProvider.notifier); return Skeletonizer( - enabled: isLoadingFeaturedPlaylists, + enabled: featuredPlaylists.isLoading, child: HorizontalPlaybuttonCardView( - items: playlists.toList(), + items: featuredPlaylists.asData?.value.items ?? [], title: Text(context.l10n.featured), - isLoadingNextPage: featuredPlaylistsQuery.isLoadingNextPage, - hasNextPage: featuredPlaylistsQuery.hasNextPage, - onFetchMore: featuredPlaylistsQuery.fetchNext, + isLoadingNextPage: featuredPlaylists.isLoadingNextPage, + hasNextPage: featuredPlaylists.asData?.value.hasMore ?? false, + onFetchMore: featuredPlaylistsNotifier.fetchMore, ), ); } diff --git a/lib/components/home/sections/friends.dart b/lib/components/home/sections/friends.dart index 6382f6fd..35ec09b0 100644 --- a/lib/components/home/sections/friends.dart +++ b/lib/components/home/sections/friends.dart @@ -1,4 +1,3 @@ -import 'dart:ffi'; import 'dart:ui'; import 'package:flutter/material.dart'; @@ -8,15 +7,16 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/components/home/sections/friends/friend_item.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomePageFriendsSection extends HookConsumerWidget { - const HomePageFriendsSection({Key? key}) : super(key: key); + const HomePageFriendsSection({super.key}); @override Widget build(BuildContext context, ref) { - final friendsQuery = useQueries.user.friendActivity(ref); - final friends = friendsQuery.data?.friends ?? FakeData.friends.friends; + final friendsQuery = ref.watch(friendsProvider); + final friends = + friendsQuery.asData?.value.friends ?? FakeData.friends.friends; final groupCount = useBreakpointValue( sm: 3, @@ -51,8 +51,8 @@ class HomePageFriendsSection extends HookConsumerWidget { }, ); - if (!friendsQuery.isLoading && - (!friendsQuery.hasData || friendsQuery.data!.friends.isEmpty)) { + if (friendsQuery.isLoading || + friendsQuery.asData?.value.friends.isEmpty == true) { return const SliverToBoxAdapter( child: SizedBox.shrink(), ); diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart index fcdadab7..b883e2cc 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/components/home/sections/friends/friend_item.dart @@ -1,10 +1,8 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/models/spotify_friends.dart'; @@ -13,9 +11,9 @@ import 'package:spotube/provider/spotify_provider.dart'; class FriendItem extends HookConsumerWidget { final SpotifyFriendActivity friend; const FriendItem({ - Key? key, + super.key, required this.friend, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -24,7 +22,6 @@ class FriendItem extends HookConsumerWidget { colorScheme: colorScheme, ) = Theme.of(context); - final queryClient = useQueryClient(); final spotify = ref.watch(spotifyProvider); return Container( @@ -86,15 +83,11 @@ class FriendItem extends HookConsumerWidget { ..onTap = () async { context.push( "/${friend.track.context.path}", - extra: !friend.track.context.path - .startsWith("album") - ? null - : await queryClient.fetchQuery( - "album/${friend.track.album.id}", - () => spotify.albums.get( - friend.track.album.id, - ), - ), + extra: + !friend.track.context.path.startsWith("album") + ? null + : await spotify.albums + .get(friend.track.context.id), ); }, ), @@ -110,12 +103,7 @@ class FriendItem extends HookConsumerWidget { recognizer: TapGestureRecognizer() ..onTap = () async { final album = - await queryClient.fetchQuery( - "album/${friend.track.album.id}", - () => spotify.albums.get( - friend.track.album.id, - ), - ); + await spotify.albums.get(friend.track.album.id); if (context.mounted) { context.push( "/album/${friend.track.album.id}", diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index 41ba235c..87f28821 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -13,28 +13,26 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeGenresSection extends HookConsumerWidget { - const HomeGenresSection({Key? key}) : super(key: key); + const HomeGenresSection({super.key}); @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme, :colorScheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final recommendationMarket = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), + final categoriesQuery = ref.watch(categoriesProvider); + final categories = useMemoized( + () => + categoriesQuery.value + ?.where((c) => (c.icons?.length ?? 0) > 0) + .take(mediaQuery.mdAndDown ? 6 : 10) + .toList() ?? + [], + [mediaQuery.mdAndDown, categoriesQuery.value], ); - final categoriesQuery = - useQueries.category.listAll(ref, recommendationMarket); - - final categories = categoriesQuery.data - ?.where((c) => (c.icons?.length ?? 0) > 0) - .take(mediaQuery.mdAndDown ? 6 : 10) - .toList() ?? - []; return SliverMainAxisGroup( slivers: [ diff --git a/lib/components/home/sections/made_for_user.dart b/lib/components/home/sections/made_for_user.dart index a3f96899..439d9c38 100644 --- a/lib/components/home/sections/made_for_user.dart +++ b/lib/components/home/sections/made_for_user.dart @@ -2,19 +2,19 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeMadeForUserSection extends HookConsumerWidget { - const HomeMadeForUserSection({Key? key}) : super(key: key); + const HomeMadeForUserSection({super.key}); @override Widget build(BuildContext context, ref) { - final madeForUser = useQueries.views.get(ref, "made-for-x-hub"); + final madeForUser = ref.watch(viewProvider("made-for-x-hub")); return SliverList.builder( - itemCount: madeForUser.data?["content"]?["items"]?.length ?? 0, + itemCount: madeForUser.value?["content"]?["items"]?.length ?? 0, itemBuilder: (context, index) { - final item = madeForUser.data?["content"]?["items"]?[index]; + final item = madeForUser.value?["content"]?["items"]?[index]; final playlists = item["content"]?["items"] ?.where((itemL2) => itemL2["type"] == "playlist") .map((itemL2) => PlaylistSimple.fromJson(itemL2)) diff --git a/lib/components/home/sections/new_releases.dart b/lib/components/home/sections/new_releases.dart index 0f4a046a..57af12fd 100644 --- a/lib/components/home/sections/new_releases.dart +++ b/lib/components/home/sections/new_releases.dart @@ -1,56 +1,35 @@ import 'package:flutter/material.dart' hide Page; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HomeNewReleasesSection extends HookConsumerWidget { - const HomeNewReleasesSection({Key? key}) : super(key: key); + const HomeNewReleasesSection({super.key}); @override Widget build(BuildContext context, ref) { final auth = ref.watch(AuthenticationNotifier.provider); - final newReleases = useQueries.album.newReleases(ref); - final userArtistsQuery = useQueries.artist.followedByMeAll(ref); - final userArtists = - userArtistsQuery.data?.map((s) => s.id!).toList() ?? const []; + final newReleases = ref.watch(albumReleasesProvider); + final newReleasesNotifier = ref.read(albumReleasesProvider.notifier); - final albums = useMemoized( - () { - final allReleases = newReleases.pages - .whereType>() - .expand((page) => page.items ?? const []) - .map((album) => TypeConversionUtils.simpleAlbum_X_Album(album)); + final albums = ref.watch(userArtistAlbumReleasesProvider); - final userArtistReleases = allReleases.where((album) { - return album.artists - ?.any((artist) => userArtists.contains(artist.id!)) == - true; - }).toList(); - - if (userArtistReleases.isEmpty) return allReleases.toList(); - return userArtistReleases; - }, - [newReleases.pages], - ); - - final hasNewReleases = newReleases.hasPageData && - userArtistsQuery.hasData && - !newReleases.isLoadingNextPage; - - if (auth == null || !hasNewReleases) return const SizedBox.shrink(); + if (auth == null || + newReleases.isLoading || + newReleases.asData?.value.items.isEmpty == true) { + return const SizedBox.shrink(); + } return HorizontalPlaybuttonCardView( items: albums, title: Text(context.l10n.new_releases), isLoadingNextPage: newReleases.isLoadingNextPage, - hasNextPage: newReleases.hasNextPage, - onFetchMore: newReleases.fetchNext, + hasNextPage: newReleases.asData?.value.hasMore ?? false, + onFetchMore: newReleasesNotifier.fetchMore, ); } } diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/components/library/playlist_generate/multi_select_field.dart index ed5eb38f..e54fc2ba 100644 --- a/lib/components/library/playlist_generate/multi_select_field.dart +++ b/lib/components/library/playlist_generate/multi_select_field.dart @@ -25,7 +25,7 @@ class MultiSelectField extends HookWidget { final bool enabled; const MultiSelectField({ - Key? key, + super.key, required this.options, required this.selectedOptions, required this.getValueForOption, @@ -36,7 +36,7 @@ class MultiSelectField extends HookWidget { this.dialogTitle, this.helperText, this.enabled = true, - }) : super(key: key); + }); Widget defaultSelectedOptionBuilder(T option) { return Chip( @@ -134,14 +134,14 @@ class _MultiSelectDialog extends HookWidget { final String? helperText; const _MultiSelectDialog({ - Key? key, + super.key, required this.dialogTitle, required this.options, required this.getValueForOption, this.optionBuilder, this.initialSelection = const [], this.helperText, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart index 87f7cb1b..d7f51ffb 100644 --- a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart +++ b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart @@ -20,12 +20,12 @@ class RecommendationAttributeDials extends HookWidget { final double base; const RecommendationAttributeDials({ - Key? key, + super.key, required this.values, required this.onChanged, required this.title, this.base = 1, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart index de169147..75437360 100644 --- a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart +++ b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart @@ -12,12 +12,12 @@ class RecommendationAttributeFields extends HookWidget { final Map? presets; const RecommendationAttributeFields({ - Key? key, + super.key, required this.values, required this.onChanged, required this.title, this.presets, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart b/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart index b1665d32..73c58deb 100644 --- a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart +++ b/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart @@ -26,7 +26,7 @@ class SeedsMultiAutocomplete extends HookWidget { final SelectedItemDisplayType selectedItemDisplayType; const SeedsMultiAutocomplete({ - Key? key, + super.key, required this.seeds, required this.fetchSeeds, required this.autocompleteOptionBuilder, @@ -35,7 +35,7 @@ class SeedsMultiAutocomplete extends HookWidget { this.inputDecoration, this.enabled = true, this.selectedItemDisplayType = SelectedItemDisplayType.wrap, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/components/library/playlist_generate/simple_track_tile.dart index 86800d06..e592969e 100644 --- a/lib/components/library/playlist_generate/simple_track_tile.dart +++ b/lib/components/library/playlist_generate/simple_track_tile.dart @@ -10,10 +10,10 @@ class SimpleTrackTile extends HookWidget { final Track track; final VoidCallback? onDelete; const SimpleTrackTile({ - Key? key, + super.key, required this.track, this.onDelete, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart index 200d1c59..07ba7a40 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -4,7 +4,6 @@ import 'package:collection/collection.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -15,42 +14,38 @@ import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class UserAlbums extends HookConsumerWidget { - const UserAlbums({Key? key}) : super(key: key); + const UserAlbums({super.key}); @override Widget build(BuildContext context, ref) { final auth = ref.watch(AuthenticationNotifier.provider); - final albumsQuery = useQueries.album.ofMine(ref); + final albumsQuery = ref.watch(favoriteAlbumsProvider); + final albumsQueryNotifier = ref.watch(favoriteAlbumsProvider.notifier); final controller = useScrollController(); final searchText = useState(''); - final allAlbums = useMemoized( - () => albumsQuery.pages - .expand((element) => element.items ?? []), - [albumsQuery.pages], - ); - final albums = useMemoized(() { if (searchText.value.isEmpty) { - return allAlbums; + return albumsQuery.asData?.value.items ?? []; } - return allAlbums - .map((e) => ( - weightedRatio(e.name!, searchText.value), - e, - )) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); - }, [allAlbums, searchText.value]); + return albumsQuery.asData?.value.items + .map((e) => ( + weightedRatio(e.name!, searchText.value), + e, + )) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() ?? + []; + }, [albumsQuery.value, searchText.value]); if (auth == null) { return const AnonymousFallback(); @@ -60,7 +55,7 @@ class UserAlbums extends HookConsumerWidget { return RefreshIndicator( onRefresh: () async { - await albumsQuery.refresh(); + ref.invalidate(favoriteAlbumsProvider); }, child: SafeArea( child: Scaffold( @@ -85,7 +80,7 @@ class UserAlbums extends HookConsumerWidget { padding: const EdgeInsets.all(8.0), controller: controller, child: Skeletonizer( - enabled: albumsQuery.pages.isEmpty, + enabled: albumsQuery.isLoading, child: Center( child: Wrap( runSpacing: 20, @@ -93,7 +88,8 @@ class UserAlbums extends HookConsumerWidget { runAlignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ - if (albumsQuery.pages.isEmpty) + if (albumsQuery.value == null || + albumsQuery.value!.items.isEmpty) ...List.generate( 10, (index) => AlbumCard(FakeData.album), @@ -107,12 +103,16 @@ class UserAlbums extends HookConsumerWidget { AlbumCard( TypeConversionUtils.simpleAlbum_X_Album(album), ), - if (albums.isNotEmpty && albumsQuery.hasNextPage) - Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: albumsQuery.fetchNext, - child: AlbumCard(FakeData.album), + if (albums.isNotEmpty && + albumsQuery.asData?.value.hasMore == true) + Skeletonizer( + enabled: true, + child: Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: albumsQueryNotifier.fetchMore, + child: AlbumCard(FakeData.album), + ), ) ], ), diff --git a/lib/components/library/user_artists.dart b/lib/components/library/user_artists.dart index 36b8528e..de6830c8 100644 --- a/lib/components/library/user_artists.dart +++ b/lib/components/library/user_artists.dart @@ -13,22 +13,22 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class UserArtists extends HookConsumerWidget { - const UserArtists({Key? key}) : super(key: key); + const UserArtists({super.key}); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); final auth = ref.watch(AuthenticationNotifier.provider); - final artistQuery = useQueries.artist.followedByMeAll(ref); + final artistQuery = ref.watch(followedArtistsProvider); final searchText = useState(''); final filteredArtists = useMemoized(() { - final artists = artistQuery.data ?? []; + final artists = artistQuery.asData?.value.items ?? []; if (searchText.value.isEmpty) { return artists.toList(); @@ -42,7 +42,7 @@ class UserArtists extends HookConsumerWidget { .where((e) => e.$1 > 50) .map((e) => e.$2) .toList(); - }, [artistQuery.data, searchText.value]); + }, [artistQuery.asData?.value.items, searchText.value]); final controller = useScrollController(); @@ -66,7 +66,7 @@ class UserArtists extends HookConsumerWidget { ), ), backgroundColor: theme.scaffoldBackgroundColor, - body: artistQuery.data?.isEmpty == true + body: artistQuery.asData?.value.items.isEmpty == true ? Padding( padding: const EdgeInsets.all(20), child: Row( @@ -80,7 +80,7 @@ class UserArtists extends HookConsumerWidget { ) : RefreshIndicator( onRefresh: () async { - await artistQuery.refresh(); + ref.invalidate(followedArtistsProvider); }, child: InterScrollbar( controller: controller, @@ -109,8 +109,9 @@ class UserArtists extends HookConsumerWidget { ) ] : filteredArtists - .mapIndexed((index, artist) => - ArtistCard(artist)) + .mapIndexed( + (index, artist) => ArtistCard(artist), + ) .toList(), ), ), diff --git a/lib/components/library/user_downloads.dart b/lib/components/library/user_downloads.dart index c8ceee66..3a1162e6 100644 --- a/lib/components/library/user_downloads.dart +++ b/lib/components/library/user_downloads.dart @@ -7,7 +7,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class UserDownloads extends HookConsumerWidget { - const UserDownloads({Key? key}) : super(key: key); + const UserDownloads({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index 10dec410..1cb5e559 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -13,9 +13,9 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadItem extends HookConsumerWidget { final Track track; const DownloadItem({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 095e6e97..b8f647a5 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -129,7 +129,7 @@ final localTracksProvider = FutureProvider>((ref) async { }); class UserLocalTracks extends HookConsumerWidget { - const UserLocalTracks({Key? key}) : super(key: key); + const UserLocalTracks({super.key}); Future playLocalTracks( WidgetRef ref, @@ -178,7 +178,7 @@ class UserLocalTracks extends HookConsumerWidget { FilledButton( onPressed: trackSnapshot.value != null ? () async { - if (trackSnapshot.value?.isNotEmpty == true) { + if (trackSnapshot.asData?.value.isNotEmpty == true) { if (!isPlaylistPlaying) { await playLocalTracks( ref, @@ -217,7 +217,7 @@ class UserLocalTracks extends HookConsumerWidget { FilledButton( child: const Icon(SpotubeIcons.refresh), onPressed: () { - ref.refresh(localTracksProvider); + ref.invalidate(localTracksProvider); }, ) ], @@ -269,7 +269,7 @@ class UserLocalTracks extends HookConsumerWidget { return Expanded( child: RefreshIndicator( onRefresh: () async { - ref.refresh(localTracksProvider); + ref.invalidate(localTracksProvider); }, child: InterScrollbar( controller: controller, diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 32e91ed6..3ff028b6 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -17,10 +17,10 @@ import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class UserPlaylists extends HookConsumerWidget { - const UserPlaylists({Key? key}) : super(key: key); + const UserPlaylists({super.key}); @override Widget build(BuildContext context, ref) { @@ -28,13 +28,9 @@ class UserPlaylists extends HookConsumerWidget { final auth = ref.watch(AuthenticationNotifier.provider); - final playlistsQuery = useQueries.playlist.ofMine(ref); - - final pagePlaylists = useMemoized( - () => playlistsQuery.pages - .expand((page) => page.items?.toList() ?? []), - [playlistsQuery.pages], - ); + final playlistsQuery = ref.watch(favoritePlaylistsProvider); + final playlistsQueryNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final likedTracksPlaylist = useMemoized( () => PlaylistSimple() @@ -58,12 +54,12 @@ class UserPlaylists extends HookConsumerWidget { if (searchText.value.isEmpty) { return [ likedTracksPlaylist, - ...pagePlaylists, + ...?playlistsQuery.asData?.value.items, ]; } return [ likedTracksPlaylist, - ...pagePlaylists, + ...?playlistsQuery.asData?.value.items, ] .map((e) => (weightedRatio(e.name!, searchText.value), e)) .sorted((a, b) => b.$1.compareTo(a.$1)) @@ -71,7 +67,7 @@ class UserPlaylists extends HookConsumerWidget { .map((e) => e.$2) .toList(); }, - [pagePlaylists, searchText.value], + [playlistsQuery, searchText.value], ); final controller = useScrollController(); @@ -81,7 +77,9 @@ class UserPlaylists extends HookConsumerWidget { } return RefreshIndicator( - onRefresh: playlistsQuery.refresh, + onRefresh: () async { + ref.invalidate(favoritePlaylistsProvider); + }, child: SafeArea( child: InterScrollbar( controller: controller, @@ -132,14 +130,14 @@ class UserPlaylists extends HookConsumerWidget { ), itemBuilder: (context, index) { if (playlists.isNotEmpty && index == playlists.length) { - if (!playlistsQuery.hasNextPage) { + if (playlistsQuery.asData?.value.hasMore != true) { return const SizedBox.shrink(); } return Waypoint( controller: controller, isGrid: true, - onTouchEdge: playlistsQuery.fetchNext, + onTouchEdge: playlistsQueryNotifier.fetchMore, child: Skeletonizer( enabled: true, child: PlaylistCard(FakeData.playlistSimple), diff --git a/lib/components/lyrics/zoom_controls.dart b/lib/components/lyrics/zoom_controls.dart index f50ea71d..73beb4ae 100644 --- a/lib/components/lyrics/zoom_controls.dart +++ b/lib/components/lyrics/zoom_controls.dart @@ -17,7 +17,7 @@ class ZoomControls extends HookWidget { final String unit; const ZoomControls({ - Key? key, + super.key, required this.value, required this.onChanged, this.min, @@ -27,7 +27,7 @@ class ZoomControls extends HookWidget { this.decreaseIcon = const Icon(SpotubeIcons.zoomOut), this.direction = Axis.horizontal, this.unit = "%", - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 458676e3..5d5a39af 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -32,10 +32,10 @@ class PlayerView extends HookConsumerWidget { final PanelController panelController; final ScrollController scrollController; const PlayerView({ - Key? key, + super.key, required this.panelController, required this.scrollController, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 7a248aa5..18168af1 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -8,7 +8,6 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; @@ -29,13 +28,12 @@ class PlayerActions extends HookConsumerWidget { this.floatingQueue = true, this.showQueue = true, this.extraActions, - Key? key, - }) : super(key: key); + super.key, + }); final logger = getLogger(PlayerActions); @override Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); final playlist = ref.watch(ProxyPlaylistNotifier.provider); final isLocalTrack = playlist.activeTrack is LocalTrack; ref.watch(downloadManagerProvider); diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 1000af18..02cbfff5 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -21,8 +21,8 @@ class PlayerControls extends HookConsumerWidget { PlayerControls({ this.palette, this.compact = false, - Key? key, - }) : super(key: key); + super.key, + }); final logger = getLogger(PlayerControls); @@ -256,7 +256,7 @@ class PlayerControls extends HookConsumerWidget { onPressed: playlist.isFetching == true ? null : () async { - switch (await audioPlayer.loopMode) { + switch (audioPlayer.loopMode) { case PlaybackLoopMode.all: audioPlayer .setLoopMode(PlaybackLoopMode.one); diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 2d63811e..1ad91a52 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -19,8 +19,8 @@ class PlayerOverlay extends HookConsumerWidget { const PlayerOverlay({ required this.albumArt, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 2784fb5f..449b6c2e 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -22,8 +22,8 @@ class PlayerQueue extends HookConsumerWidget { final bool floating; const PlayerQueue({ this.floating = true, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index 66cb9ef5..fd97fd74 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -13,8 +13,7 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { final String? albumArt; final Color? color; - const PlayerTrackDetails({Key? key, this.albumArt, this.color}) - : super(key: key); + const PlayerTrackDetails({super.key, this.albumArt, this.color}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 58b1ca8c..c805cb42 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -45,9 +45,9 @@ final sourceInfoToIconMap = { class SiblingTracksSheet extends HookConsumerWidget { final bool floating; const SiblingTracksSheet({ - Key? key, + super.key, this.floating = true, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/player/volume_slider.dart b/lib/components/player/volume_slider.dart index 75445125..7596a347 100644 --- a/lib/components/player/volume_slider.dart +++ b/lib/components/player/volume_slider.dart @@ -8,9 +8,9 @@ import 'package:spotube/provider/volume_provider.dart'; class VolumeSlider extends HookConsumerWidget { final bool fullWidth; const VolumeSlider({ - Key? key, + super.key, this.fullWidth = false, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index f429a0ab..ffbfbae9 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -1,14 +1,11 @@ -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/extensions/infinite_query.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -16,48 +13,30 @@ class PlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; const PlaylistCard( this.playlist, { - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final queryClient = QueryClient.of(context); - final tracks = useState?>(null); bool isPlaylistPlaying = useMemoized( () => playlistQueue.containsCollection(playlist.id!), [playlistQueue, playlist.id], ); final updating = useState(false); - final spotify = ref.watch(spotifyProvider); - final me = useQueries.user.me(ref); + final me = ref.watch(meProvider); Future> fetchAllTracks() async { if (playlist.id == 'user-liked-tracks') { - return await queryClient.fetchQuery( - "user-liked-tracks", - () => useQueries.playlist.likedTracks(spotify), - ) ?? - []; + return await ref.read(likedTracksProvider.future); } - final query = queryClient.createInfiniteQuery, dynamic, int>( - "playlist-tracks/${playlist.id}", - (page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!), - initialPage: 0, - nextPage: useQueries.playlist.tracksOfQueryNextPage, - ); + await ref.read(playlistTracksProvider(playlist.id!).future); - return await query.fetchAllTracks( - getAllTracks: () async { - final res = - await spotify.playlists.getTracksByPlaylistId(playlist.id!).all(); - return res.toList(); - }, - ); + return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); } return PlaybuttonCard( @@ -71,7 +50,8 @@ class PlaylistCard extends HookConsumerWidget { isPlaying: isPlaylistPlaying, isLoading: (isPlaylistPlaying && playlistQueue.isFetching) || updating.value, - isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null, + isOwner: playlist.owner?.id == me.asData?.value.id && + me.asData?.value.id != null, onTap: () { ServiceUtils.push( context, @@ -94,7 +74,6 @@ class PlaylistCard extends HookConsumerWidget { await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(playlist.id!); - tracks.value = fetchedTracks; } finally { if (context.mounted) { updating.value = false; @@ -112,10 +91,9 @@ class PlaylistCard extends HookConsumerWidget { playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(playlist.id!); - tracks.value = fetchedTracks; if (context.mounted) { final snackbar = SnackBar( - content: Text("Added ${tracks.value?.length} tracks to queue"), + content: Text("Added ${fetchedTracks.length} tracks to queue"), action: SnackBarAction( label: "Undo", onPressed: () { diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index 2e11a209..669dce51 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:form_validator/form_validator.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:spotify/spotify.dart'; @@ -13,10 +14,8 @@ 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/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/mutations/playlist.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCreateDialog extends HookConsumerWidget { @@ -24,10 +23,10 @@ class PlaylistCreateDialog extends HookConsumerWidget { final List trackIds; final String? playlistId; PlaylistCreateDialog({ - Key? key, + super.key, this.trackIds = const [], this.playlistId, - }) : super(key: key); + }); final formKey = GlobalKey(); @@ -37,13 +36,16 @@ class PlaylistCreateDialog extends HookConsumerWidget { child: Scaffold( backgroundColor: Colors.transparent, body: HookBuilder(builder: (context) { - final userPlaylists = useQueries.playlist.ofMine(ref); + final userPlaylists = ref.watch(favoritePlaylistsProvider); + final playlist = ref.watch(playlistProvider(playlistId ?? "")); + final playlistNotifier = + ref.watch(playlistProvider(playlistId ?? "").notifier); + final updatingPlaylist = useMemoized( - () => userPlaylists.pages - .expand((p) => p.items ?? []) + () => userPlaylists.asData?.value.items .firstWhereOrNull((playlist) => playlist.id == playlistId), [ - userPlaylists.pages, + userPlaylists.asData?.value.items, playlistId, ], ); @@ -84,28 +86,10 @@ class PlaylistCreateDialog extends HookConsumerWidget { } }, [scaffold, l10n, theme]); - final playlistCreateMutation = useMutations.playlist.create( - ref, - trackIds: trackIds, - onData: (value) { - Navigator.pop(context); - }, - onError: onError, - ); - - final playlistUpdateMutation = useMutations.playlist.update( - ref, - playlistId: playlistId, - onData: (value) { - Navigator.pop(context); - }, - onError: onError, - ); - Future onCreate() async { if (!formKey.currentState!.validate()) return; - final PlaylistCRUDVariables payload = ( + final PlaylistInput payload = ( playlistName: playlistName.text, collaborative: collaborative.value, public: public.value, @@ -118,9 +102,14 @@ class PlaylistCreateDialog extends HookConsumerWidget { ); if (isUpdatingPlaylist) { - await playlistUpdateMutation.mutate(payload); + await playlistNotifier.modify(payload, onError); } else { - await playlistCreateMutation.mutate(payload); + await playlistNotifier.create(payload, onError); + } + + if (context.mounted && + !ref.read(playlistProvider(playlistId ?? "")).hasError) { + context.pop(); } } @@ -138,7 +127,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { }, ), FilledButton( - onPressed: onCreate, + onPressed: playlist.isLoading ? null : onCreate, child: Text( isUpdatingPlaylist ? context.l10n.update @@ -275,7 +264,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { } class PlaylistCreateDialogButton extends HookConsumerWidget { - const PlaylistCreateDialogButton({Key? key}) : super(key: key); + const PlaylistCreateDialogButton({super.key}); showPlaylistDialog(BuildContext context, SpotifyApi spotify) { showDialog( diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 617e760b..3f70490a 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -25,7 +25,7 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class BottomPlayer extends HookConsumerWidget { - BottomPlayer({Key? key}) : super(key: key); + BottomPlayer({super.key}); final logger = getLogger(BottomPlayer); @override diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index a55ef947..21259a94 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -15,10 +15,10 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -31,8 +31,8 @@ class Sidebar extends HookConsumerWidget { required this.selectedIndex, required this.onSelectedIndexChanged, required this.child, - Key? key, - }) : super(key: key); + super.key, + }); static Widget brandLogo() { return Container( @@ -195,7 +195,7 @@ class Sidebar extends HookConsumerWidget { } class SidebarHeader extends HookWidget { - const SidebarHeader({Key? key}) : super(key: key); + const SidebarHeader({super.key}); @override Widget build(BuildContext context) { @@ -234,15 +234,15 @@ class SidebarHeader extends HookWidget { class SidebarFooter extends HookConsumerWidget { const SidebarFooter({ - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final me = useQueries.user.me(ref); - final data = me.data; + final me = ref.watch(meProvider); + final data = me.asData?.value; final avatarImg = TypeConversionUtils.image_X_UrlString( data?.images, diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 0853c60c..489399e5 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -23,8 +23,8 @@ class SpotubeNavigationBar extends HookConsumerWidget { const SpotubeNavigationBar({ required this.selectedIndex, required this.onSelectedIndexChanged, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart index e0c3d618..8d098375 100644 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ b/lib/components/settings/color_scheme_picker_dialog.dart @@ -8,9 +8,9 @@ import 'package:system_theme/system_theme.dart'; class SpotubeColor extends Color { final String name; - const SpotubeColor(int color, {required this.name}) : super(color); + const SpotubeColor(super.color, {required this.name}); - const SpotubeColor.from(int value, {required this.name}) : super(value); + const SpotubeColor.from(super.value, {required this.name}); factory SpotubeColor.fromString(String string) { final slices = string.split(":"); @@ -44,7 +44,7 @@ final Set colorsMap = { }; class ColorSchemePickerDialog extends HookConsumerWidget { - const ColorSchemePickerDialog({Key? key}) : super(key: key); + const ColorSchemePickerDialog({super.key}); @override Widget build(BuildContext context, ref) { @@ -119,8 +119,8 @@ class ColorTile extends StatelessWidget { this.onPressed, this.tooltip = "", this.isCompact = false, - Key? key, - }) : super(key: key); + super.key, + }); factory ColorTile.compact({ required Color color, diff --git a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart index 45f22825..02fced52 100644 --- a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart +++ b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart @@ -12,13 +12,13 @@ class Action extends StatelessWidget { final bool isExpanded; final Color? backgroundColor; const Action({ - Key? key, + super.key, required this.icon, required this.text, required this.onPressed, this.isExpanded = true, this.backgroundColor, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/animated_gradient.dart b/lib/components/shared/animated_gradient.dart index b6485f6b..aaba2ff9 100644 --- a/lib/components/shared/animated_gradient.dart +++ b/lib/components/shared/animated_gradient.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; class AnimateGradient extends HookWidget { const AnimateGradient({ - Key? key, + super.key, required this.primaryColors, required this.secondaryColors, this.child, @@ -17,8 +17,7 @@ class AnimateGradient extends HookWidget { this.reverse = true, }) : assert(primaryColors.length >= 2), assert(primaryColors.length == secondaryColors.length), - _controller = controller, - super(key: key); + _controller = controller; /// [controller]: pass this to have a fine control over the [Animation] final AnimationController? _controller; diff --git a/lib/components/shared/compact_search.dart b/lib/components/shared/compact_search.dart index 70815291..d37cb673 100644 --- a/lib/components/shared/compact_search.dart +++ b/lib/components/shared/compact_search.dart @@ -11,12 +11,12 @@ class CompactSearch extends HookWidget { final Color? iconColor; const CompactSearch({ - Key? key, + super.key, this.onChanged, this.placeholder = "Search...", this.icon = SpotubeIcons.search, this.iconColor, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/dialogs/confirm_download_dialog.dart b/lib/components/shared/dialogs/confirm_download_dialog.dart index c371e803..486310a7 100644 --- a/lib/components/shared/dialogs/confirm_download_dialog.dart +++ b/lib/components/shared/dialogs/confirm_download_dialog.dart @@ -5,7 +5,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; class ConfirmDownloadDialog extends StatelessWidget { - const ConfirmDownloadDialog({Key? key}) : super(key: key); + const ConfirmDownloadDialog({super.key}); @override Widget build(BuildContext context) { @@ -82,7 +82,7 @@ class ConfirmDownloadDialog extends StatelessWidget { class BulletPoint extends StatelessWidget { final String text; - const BulletPoint(this.text, {Key? key}) : super(key: key); + const BulletPoint(this.text, {super.key}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/dialogs/piped_down_dialog.dart b/lib/components/shared/dialogs/piped_down_dialog.dart index 6220adeb..b1717a2a 100644 --- a/lib/components/shared/dialogs/piped_down_dialog.dart +++ b/lib/components/shared/dialogs/piped_down_dialog.dart @@ -5,7 +5,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class PipedDownDialog extends HookConsumerWidget { - const PipedDownDialog({Key? key}) : super(key: key); + const PipedDownDialog({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index 51b77c76..1f1807da 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart @@ -1,4 +1,3 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; @@ -8,8 +7,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistAddTrackDialog extends HookConsumerWidget { @@ -19,33 +17,40 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { const PlaylistAddTrackDialog({ required this.tracks, required this.openFromPlaylist, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme) = Theme.of(context); - final spotify = ref.watch(spotifyProvider); - final userPlaylists = useQueries.playlist.ofMineAll(ref); + final userPlaylists = ref.watch(favoritePlaylistsProvider); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); - final me = useQueries.user.me(ref); + final me = ref.watch(meProvider); final filteredPlaylists = useMemoized( () => - userPlaylists.data - ?.where( + userPlaylists.asData?.value.items + .where( (playlist) => playlist.owner?.id != null && - playlist.owner!.id == me.data?.id && + playlist.owner!.id == me.asData?.value.id && playlist.id != openFromPlaylist, ) .toList() ?? [], - [userPlaylists.data, me.data?.id, openFromPlaylist], + [userPlaylists.asData?.value, me.asData?.value.id, openFromPlaylist], ); final playlistsCheck = useState({}); - final queryClient = useQueryClient(); + + useEffect(() { + if (userPlaylists.asData?.value != null) { + favoritePlaylistsNotifier.fetchAll(); + } + return null; + }, [userPlaylists.asData?.value]); Future onAdd() async { final selectedPlaylists = playlistsCheck.value.entries @@ -54,21 +59,12 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { await Future.wait( selectedPlaylists.map( - (playlistId) => spotify.playlists.addTracks( - tracks - .map( - (track) => track.uri!, - ) - .toList(), - playlistId), + (playlistId) => favoritePlaylistsNotifier.addTracks( + playlistId, + tracks.map((e) => e.id!).toList(), + ), ), ).then((_) => Navigator.pop(context, true)); - - await queryClient.refreshQueries( - selectedPlaylists - .map((playlistId) => "playlist-tracks/$playlistId") - .toList(), - ); } return AlertDialog( diff --git a/lib/components/shared/dialogs/replace_downloaded_dialog.dart b/lib/components/shared/dialogs/replace_downloaded_dialog.dart index 77721041..00461d34 100644 --- a/lib/components/shared/dialogs/replace_downloaded_dialog.dart +++ b/lib/components/shared/dialogs/replace_downloaded_dialog.dart @@ -8,8 +8,7 @@ final replaceDownloadedFileState = StateProvider((ref) => null); class ReplaceDownloadedDialog extends ConsumerWidget { final Track track; - const ReplaceDownloadedDialog({required this.track, Key? key}) - : super(key: key); + const ReplaceDownloadedDialog({required this.track, super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/shared/dialogs/track_details_dialog.dart index 8634776f..4e65b8e5 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/shared/dialogs/track_details_dialog.dart @@ -13,9 +13,9 @@ import 'package:spotube/extensions/duration.dart'; class TrackDetailsDialog extends HookWidget { final Track track; const TrackDetailsDialog({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/expandable_search/expandable_search.dart b/lib/components/shared/expandable_search/expandable_search.dart index 75ac6841..157e180f 100644 --- a/lib/components/shared/expandable_search/expandable_search.dart +++ b/lib/components/shared/expandable_search/expandable_search.dart @@ -10,12 +10,12 @@ class ExpandableSearchField extends StatelessWidget { final FocusNode searchFocus; const ExpandableSearchField({ - Key? key, + super.key, required this.isFiltering, required this.onChangeFiltering, required this.searchController, required this.searchFocus, - }) : super(key: key); + }); @override Widget build(BuildContext context) { @@ -60,12 +60,12 @@ class ExpandableSearchButton extends StatelessWidget { final ValueChanged? onPressed; const ExpandableSearchButton({ - Key? key, + super.key, required this.isFiltering, required this.searchFocus, this.icon = const Icon(SpotubeIcons.filter), this.onPressed, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/shared/fallbacks/anonymous_fallback.dart index aea7bf38..ace7ec64 100644 --- a/lib/components/shared/fallbacks/anonymous_fallback.dart +++ b/lib/components/shared/fallbacks/anonymous_fallback.dart @@ -8,9 +8,9 @@ import 'package:spotube/utils/service_utils.dart'; class AnonymousFallback extends ConsumerWidget { final Widget? child; const AnonymousFallback({ - Key? key, + super.key, this.child, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/fallbacks/not_found.dart b/lib/components/shared/fallbacks/not_found.dart index f45573ad..5a74f672 100644 --- a/lib/components/shared/fallbacks/not_found.dart +++ b/lib/components/shared/fallbacks/not_found.dart @@ -3,7 +3,7 @@ import 'package:spotube/collections/assets.gen.dart'; class NotFound extends StatelessWidget { final bool vertical; - const NotFound({Key? key, this.vertical = false}) : super(key: key); + const NotFound({super.key, this.vertical = false}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart index 81ccffdb..a733c36c 100644 --- a/lib/components/shared/heart_button.dart +++ b/lib/components/shared/heart_button.dart @@ -1,5 +1,3 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -8,8 +6,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class HeartButton extends HookConsumerWidget { final bool isLiked; @@ -23,8 +20,8 @@ class HeartButton extends HookConsumerWidget { this.color, this.tooltip, this.icon, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -60,90 +57,50 @@ class HeartButton extends HookConsumerWidget { typedef UseTrackToggleLike = ({ bool isLiked, - Mutation toggleTrackLike, - Query me, + Future Function(Track track) toggleTrackLike, }); UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { - final me = useQueries.user.me(ref); - - final savedTracks = useQueries.playlist.likedTracksQuery(ref); + final savedTracks = ref.watch(likedTracksProvider); + final savedTracksNotifier = ref.watch(likedTracksProvider.notifier); final isLiked = useMemoized( - () => savedTracks.data?.any((element) => element.id == track.id) ?? false, - [savedTracks.data, track.id], + () => + savedTracks.asData?.value.any((element) => element.id == track.id) ?? + false, + [savedTracks.value, track.id], ); - final mounted = useIsMounted(); - final scrobblerNotifier = ref.read(scrobblerProvider.notifier); - final toggleTrackLike = useMutations.track.toggleFavorite( - ref, - track.id!, - onMutate: (isLiked) { - if (isLiked) { - savedTracks.setData( - savedTracks.data - ?.where((element) => element.id != track.id) - .toList() ?? - [], - ); - } else { - savedTracks.setData( - [ - ...?savedTracks.data, - track, - ], - ); - } - return isLiked; - }, - onData: (isLiked, recoveryData) async { - await savedTracks.refresh(); - if (isLiked) { + return ( + isLiked: isLiked, + toggleTrackLike: (track) async { + await savedTracksNotifier.toggleFavorite(track); + + if (!isLiked) { await scrobblerNotifier.love(track); } else { await scrobblerNotifier.unlove(track); } }, - onError: (payload, isLiked) { - if (!mounted()) return; - - if (isLiked != true) { - savedTracks.setData( - savedTracks.data - ?.where((element) => element.id != track.id) - .toList() ?? - [], - ); - } else { - savedTracks.setData( - [ - ...?savedTracks.data, - track, - ], - ); - } - }, ); - - return (isLiked: isLiked, toggleTrackLike: toggleTrackLike, me: me); } class TrackHeartButton extends HookConsumerWidget { final Track track; const TrackHeartButton({ - Key? key, + super.key, required this.track, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final savedTracks = useQueries.playlist.likedTracksQuery(ref); - final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref); + final savedTracks = ref.watch(likedTracksProvider); + final me = ref.watch(meProvider); + final (:isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref); - if (me.isLoading || !me.hasData) { + if (me.isLoading) { return const CircularProgressIndicator(); } @@ -152,104 +109,9 @@ class TrackHeartButton extends HookConsumerWidget { ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, isLiked: isLiked, - onPressed: savedTracks.hasData + onPressed: savedTracks.value != null ? () { - toggleTrackLike.mutate(isLiked); - } - : null, - ); - } -} - -class PlaylistHeartButton extends HookConsumerWidget { - final PlaylistSimple playlist; - final IconData? icon; - final ValueChanged? onData; - - const PlaylistHeartButton({ - required this.playlist, - Key? key, - this.icon, - this.onData, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final me = useQueries.user.me(ref); - - final isLikedQuery = useQueries.playlist.doesUserFollow( - ref, - playlist.id!, - me.data?.id ?? '', - ); - - final togglePlaylistLike = useMutations.playlist.toggleFavorite( - ref, - playlist.id!, - refreshQueries: [ - isLikedQuery.key, - ], - onData: onData, - ); - - if (me.isLoading || !me.hasData) { - return const CircularProgressIndicator(); - } - - return HeartButton( - isLiked: isLikedQuery.data ?? false, - tooltip: isLikedQuery.data ?? false - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - color: Colors.white, - icon: icon, - onPressed: isLikedQuery.hasData - ? () { - togglePlaylistLike.mutate(isLikedQuery.data!); - } - : null, - ); - } -} - -class AlbumHeartButton extends HookConsumerWidget { - final AlbumSimple album; - - const AlbumHeartButton({ - required this.album, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final client = useQueryClient(); - final me = useQueries.user.me(ref); - - final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); - final isLiked = albumIsSaved.data ?? false; - - final toggleAlbumLike = useMutations.album.toggleFavorite( - ref, - album.id!, - refreshQueries: [albumIsSaved.key], - onData: (_, __) async { - await client.refreshInfiniteQueryAllPages("current-user-albums"); - }, - ); - - if (me.isLoading || !me.hasData) { - return const CircularProgressIndicator(); - } - - return HeartButton( - isLiked: isLiked, - tooltip: isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - color: Colors.white, - onPressed: albumIsSaved.hasData - ? () { - toggleAlbumLike.mutate(isLiked); + toggleTrackLike(track); } : null, ); diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index dc9d30da..8f0e6048 100644 --- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -24,13 +24,12 @@ class HorizontalPlaybuttonCardView extends HookWidget { required this.hasNextPage, required this.onFetchMore, required this.isLoadingNextPage, - Key? key, - }) : assert( + super.key, + }) : assert( items is List || items is List || items is List, - ), - super(key: key); + ); @override Widget build(BuildContext context) { @@ -85,11 +84,11 @@ class HorizontalPlaybuttonCardView extends HookWidget { itemBuilder: (context, index) { final item = items[index]; - return switch (item.runtimeType) { - PlaylistSimple => + return switch (item) { + PlaylistSimple() => PlaylistCard(item as PlaylistSimple), - Album => AlbumCard(item as Album), - Artist => Padding( + Album() => AlbumCard(item as Album), + Artist() => Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0), child: ArtistCard(item as Artist), diff --git a/lib/components/shared/hover_builder.dart b/lib/components/shared/hover_builder.dart index ec60848e..7793e744 100644 --- a/lib/components/shared/hover_builder.dart +++ b/lib/components/shared/hover_builder.dart @@ -7,8 +7,8 @@ class HoverBuilder extends HookWidget { const HoverBuilder({ required this.builder, this.permanentState, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/image/universal_image.dart b/lib/components/shared/image/universal_image.dart index 04c62478..d8902e63 100644 --- a/lib/components/shared/image/universal_image.dart +++ b/lib/components/shared/image/universal_image.dart @@ -20,8 +20,8 @@ class UniversalImage extends HookWidget { this.placeholder, this.fit, this.scale = 1, - Key? key, - }) : super(key: key); + super.key, + }); static ImageProvider imageProvider( String path, { diff --git a/lib/components/shared/links/anchor_button.dart b/lib/components/shared/links/anchor_button.dart index b1b1cfea..d78bbf96 100644 --- a/lib/components/shared/links/anchor_button.dart +++ b/lib/components/shared/links/anchor_button.dart @@ -11,13 +11,13 @@ class AnchorButton extends HookWidget { const AnchorButton( this.text, { - Key? key, + super.key, this.onTap, this.textAlign, this.overflow, this.maxLines, this.style = const TextStyle(), - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/links/hyper_link.dart b/lib/components/shared/links/hyper_link.dart index fd31298e..f84517b4 100644 --- a/lib/components/shared/links/hyper_link.dart +++ b/lib/components/shared/links/hyper_link.dart @@ -13,12 +13,12 @@ class Hyperlink extends StatelessWidget { const Hyperlink( this.text, this.url, { - Key? key, + super.key, this.textAlign, this.overflow, this.style = const TextStyle(), this.maxLines, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/links/link_text.dart b/lib/components/shared/links/link_text.dart index d7b00b72..db7b6358 100644 --- a/lib/components/shared/links/link_text.dart +++ b/lib/components/shared/links/link_text.dart @@ -15,14 +15,14 @@ class LinkText extends StatelessWidget { const LinkText( this.text, this.route, { - Key? key, + super.key, this.textAlign, this.extra, this.overflow, this.style = const TextStyle(), this.maxLines, this.push = false, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index 9aa2d4a8..ff40bac7 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -27,7 +27,7 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget final Widget? title; const PageWindowTitleBar({ - Key? key, + super.key, this.actions, this.title, this.toolbarOpacity = 1, @@ -42,7 +42,7 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget this.titleTextStyle, this.titleWidth, this.toolbarTextStyle, - }) : super(key: key); + }); @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -107,9 +107,9 @@ class _PageWindowTitleBarState extends ConsumerState { class WindowTitleBarButtons extends HookConsumerWidget { final Color? foregroundColor; const WindowTitleBarButtons({ - Key? key, + super.key, this.foregroundColor, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -277,14 +277,13 @@ class WindowButton extends StatelessWidget { final VoidCallback? onPressed; WindowButton( - {Key? key, + {super.key, WindowButtonColors? colors, this.builder, @required this.iconBuilder, this.padding, this.onPressed, - this.animate = false}) - : super(key: key) { + this.animate = false}) { this.colors = colors ?? _defaultButtonColors; } @@ -350,49 +349,40 @@ class WindowButton extends StatelessWidget { class MinimizeWindowButton extends WindowButton { MinimizeWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, + {super.key, + super.colors, + super.onPressed, bool? animate}) : super( - key: key, - colors: colors, animate: animate ?? false, iconBuilder: (buttonContext) => MinimizeIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } class MaximizeWindowButton extends WindowButton { MaximizeWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, + {super.key, + super.colors, + super.onPressed, bool? animate}) : super( - key: key, - colors: colors, animate: animate ?? false, iconBuilder: (buttonContext) => MaximizeIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } class RestoreWindowButton extends WindowButton { RestoreWindowButton( - {Key? key, - WindowButtonColors? colors, - VoidCallback? onPressed, + {super.key, + super.colors, + super.onPressed, bool? animate}) : super( - key: key, - colors: colors, animate: animate ?? false, iconBuilder: (buttonContext) => RestoreIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } @@ -404,17 +394,15 @@ final _defaultCloseButtonColors = WindowButtonColors( class CloseWindowButton extends WindowButton { CloseWindowButton( - {Key? key, + {super.key, WindowButtonColors? colors, - VoidCallback? onPressed, + super.onPressed, bool? animate}) : super( - key: key, colors: colors ?? _defaultCloseButtonColors, animate: animate ?? false, iconBuilder: (buttonContext) => CloseIcon(color: buttonContext.iconColor), - onPressed: onPressed, ); } @@ -423,7 +411,7 @@ class CloseWindowButton extends WindowButton { /// Close class CloseIcon extends StatelessWidget { final Color color; - const CloseIcon({Key? key, required this.color}) : super(key: key); + const CloseIcon({super.key, required this.color}); @override Widget build(BuildContext context) => Align( alignment: Alignment.topLeft, @@ -444,13 +432,13 @@ class CloseIcon extends StatelessWidget { /// Maximize class MaximizeIcon extends StatelessWidget { final Color color; - const MaximizeIcon({Key? key, required this.color}) : super(key: key); + const MaximizeIcon({super.key, required this.color}); @override Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color)); } class _MaximizePainter extends _IconPainter { - _MaximizePainter(Color color) : super(color); + _MaximizePainter(super.color); @override void paint(Canvas canvas, Size size) { Paint p = getPaint(color); @@ -462,15 +450,15 @@ class _MaximizePainter extends _IconPainter { class RestoreIcon extends StatelessWidget { final Color color; const RestoreIcon({ - Key? key, + super.key, required this.color, - }) : super(key: key); + }); @override Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color)); } class _RestorePainter extends _IconPainter { - _RestorePainter(Color color) : super(color); + _RestorePainter(super.color); @override void paint(Canvas canvas, Size size) { Paint p = getPaint(color); @@ -487,13 +475,13 @@ class _RestorePainter extends _IconPainter { /// Minimize class MinimizeIcon extends StatelessWidget { final Color color; - const MinimizeIcon({Key? key, required this.color}) : super(key: key); + const MinimizeIcon({super.key, required this.color}); @override Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color)); } class _MinimizePainter extends _IconPainter { - _MinimizePainter(Color color) : super(color); + _MinimizePainter(super.color); @override void paint(Canvas canvas, Size size) { Paint p = getPaint(color); @@ -512,7 +500,7 @@ abstract class _IconPainter extends CustomPainter { } class _AlignedPaint extends StatelessWidget { - const _AlignedPaint(this.painter, {Key? key}) : super(key: key); + const _AlignedPaint(this.painter); final CustomPainter painter; @override @@ -547,8 +535,7 @@ T? _ambiguate(T? value) => value; class MouseStateBuilder extends StatefulWidget { final MouseStateBuilderCB builder; final VoidCallback? onPressed; - const MouseStateBuilder({Key? key, required this.builder, this.onPressed}) - : super(key: key); + const MouseStateBuilder({super.key, required this.builder, this.onPressed}); @override _MouseStateBuilderState createState() => _MouseStateBuilderState(); } diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/shared/panels/helpers.dart index 2e754bdf..7dad96d5 100644 --- a/lib/components/shared/panels/helpers.dart +++ b/lib/components/shared/panels/helpers.dart @@ -47,8 +47,7 @@ class ForceDraggableWidgetRenderBox extends RenderPointerListener { /// To make [ForceDraggableWidget] work in [Scrollable] widgets class PanelScrollPhysics extends ScrollPhysics { final PanelController controller; - const PanelScrollPhysics({required this.controller, ScrollPhysics? parent}) - : super(parent: parent); + const PanelScrollPhysics({required this.controller, super.parent}); @override PanelScrollPhysics applyTo(ScrollPhysics? ancestor) { return PanelScrollPhysics( diff --git a/lib/components/shared/panels/sliding_up_panel.dart b/lib/components/shared/panels/sliding_up_panel.dart index 137d5eb7..e99fe261 100644 --- a/lib/components/shared/panels/sliding_up_panel.dart +++ b/lib/components/shared/panels/sliding_up_panel.dart @@ -146,7 +146,7 @@ class SlidingUpPanel extends StatefulWidget { final BoxDecoration? panelDecoration; const SlidingUpPanel( - {Key? key, + {super.key, this.body, this.collapsed, this.minHeight = 100.0, @@ -176,8 +176,7 @@ class SlidingUpPanel extends StatefulWidget { this.panelBuilder}) : assert(panelBuilder != null), assert(0 <= backdropOpacity && backdropOpacity <= 1.0), - assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0), - super(key: key); + assert(snapPoint == null || 0 < snapPoint && snapPoint < 1.0); @override SlidingUpPanelState createState() => SlidingUpPanelState(); diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index a8a75d30..80a27eb0 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -43,8 +43,8 @@ class PlaybuttonCard extends HookWidget { this.onAddToQueuePressed, this.onTap, this.isOwner = false, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/shimmers/shimmer_lyrics.dart b/lib/components/shared/shimmers/shimmer_lyrics.dart index b225c008..03816202 100644 --- a/lib/components/shared/shimmers/shimmer_lyrics.dart +++ b/lib/components/shared/shimmers/shimmer_lyrics.dart @@ -5,7 +5,7 @@ import 'package:gap/gap.dart'; import 'package:skeletonizer/skeletonizer.dart'; class ShimmerLyrics extends HookWidget { - const ShimmerLyrics({Key? key}) : super(key: key); + const ShimmerLyrics({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/sort_tracks_dropdown.dart b/lib/components/shared/sort_tracks_dropdown.dart index ab35b2e3..be72d689 100644 --- a/lib/components/shared/sort_tracks_dropdown.dart +++ b/lib/components/shared/sort_tracks_dropdown.dart @@ -11,8 +11,8 @@ class SortTracksDropdown extends StatelessWidget { const SortTracksDropdown({ this.onChanged, this.value, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index d5798189..017f04aa 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -5,7 +5,7 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { final List tabs; - const ThemedButtonsTabBar({Key? key, required this.tabs}) : super(key: key); + const ThemedButtonsTabBar({super.key, required this.tabs}); @override Widget build(BuildContext context) { diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index a094259d..8522738d 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -1,6 +1,5 @@ import 'dart:io'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -23,9 +22,8 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/search.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -53,13 +51,13 @@ class TrackOptions extends HookConsumerWidget { final ObjectRef?>? showMenuCbRef; final Widget? icon; const TrackOptions({ - Key? key, + super.key, required this.track, this.showMenuCbRef, this.userPlaylist = false, this.playlistId, this.icon, - }) : super(key: key); + }); void actionShare(BuildContext context, Track track) { final data = "https://open.spotify.com/track/${track.id}"; @@ -99,21 +97,10 @@ class TrackOptions extends HookConsumerWidget { final playlist = ref.read(ProxyPlaylistNotifier.provider); final spotify = ref.read(spotifyProvider); final query = "${track.name} Radio"; - final pages = await QueryClient.of(context) - .fetchInfiniteQueryJob, dynamic, int, SearchParams>( - job: SearchQueries.queryJob(query), - args: ( - spotify: spotify, - searchType: SearchType.playlist, - query: query, - ), - ) ?? - []; + final pages = + await spotify.search.get(query, types: [SearchType.playlist]).first(); - final radios = pages - .expand((e) => e.items?.toList() ?? []) - .toList() - .cast(); + final radios = pages.map((e) => e.items).toList().cast(); final artists = track.artists!.map((e) => e.name); @@ -176,6 +163,7 @@ class TrackOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloadManager = ref.watch(downloadManagerProvider.notifier); final blacklist = ref.watch(BlackListNotifier.provider); + final me = ref.watch(meProvider); final favorites = useTrackToggleLike(track, ref); @@ -190,10 +178,8 @@ class TrackOptions extends HookConsumerWidget { ); final removingTrack = useState(null); - final removeTrack = useMutations.playlist.removeTrackOf( - ref, - playlistId ?? "", - ); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final isInQueue = useMemoized(() { if (playlist.activeTrack == null) return false; @@ -220,7 +206,7 @@ class TrackOptions extends HookConsumerWidget { break; case TrackOptionValue.delete: await File((track as LocalTrack).path).delete(); - ref.refresh(localTracksProvider); + ref.invalidate(localTracksProvider); break; case TrackOptionValue.addToQueue: await playback.addTrack(track); @@ -257,14 +243,15 @@ class TrackOptions extends HookConsumerWidget { ); break; case TrackOptionValue.favorite: - favorites.toggleTrackLike.mutate(favorites.isLiked); + favorites.toggleTrackLike(track); break; case TrackOptionValue.addToPlaylist: actionAddToPlaylist(context, track); break; case TrackOptionValue.removeFromPlaylist: removingTrack.value = track.uri; - removeTrack.mutate(track.uri!); + favoritePlaylistsNotifier + .removeTracks(playlistId ?? "", [track.id!]); break; case TrackOptionValue.blacklist: if (isBlackListed) { @@ -328,7 +315,7 @@ class TrackOptions extends HookConsumerWidget { ), ], children: switch (track.runtimeType) { - LocalTrack => [ + LocalTrack() => [ PopSheetEntry( value: TrackOptionValue.delete, leading: const Icon(SpotubeIcons.trash), @@ -361,7 +348,7 @@ class TrackOptions extends HookConsumerWidget { leading: const Icon(SpotubeIcons.queueRemove), title: Text(context.l10n.remove_from_queue), ), - if (favorites.me.hasData) + if (me.value != null) PopSheetEntry( value: TrackOptionValue.favorite, leading: favorites.isLiked @@ -391,10 +378,7 @@ class TrackOptions extends HookConsumerWidget { if (userPlaylist && auth != null) PopSheetEntry( value: TrackOptionValue.removeFromPlaylist, - leading: (removeTrack.isMutating || !removeTrack.hasData) && - removingTrack.value == track.uri - ? const CircularProgressIndicator() - : const Icon(SpotubeIcons.removeFilled), + leading: const Icon(SpotubeIcons.removeFilled), title: Text(context.l10n.remove_from_playlist), ), PopSheetEntry( diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index d268c783..ecadc1c6 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -32,7 +32,7 @@ class TrackTile extends HookConsumerWidget { final List? leadingActions; const TrackTile({ - Key? key, + super.key, this.index, required this.track, this.selected = false, @@ -42,7 +42,7 @@ class TrackTile extends HookConsumerWidget { this.userPlaylist = false, this.playlistId, this.leadingActions, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index 33c8fa82..661e5af4 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -19,7 +19,7 @@ import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class TrackViewBodySection extends HookConsumerWidget { - const TrackViewBodySection({Key? key}) : super(key: key); + const TrackViewBodySection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart index 7e4522a0..3a1538a3 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart @@ -13,10 +13,10 @@ class TrackViewBodyHeaders extends HookConsumerWidget { final FocusNode searchFocus; const TrackViewBodyHeaders({ - Key? key, + super.key, required this.isFiltering, required this.searchFocus, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart index 583c9107..5560ef3f 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart @@ -13,7 +13,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class TrackViewBodyOptions extends HookConsumerWidget { - const TrackViewBodyOptions({Key? key}) : super(key: key); + const TrackViewBodyOptions({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart index ca3c6706..d32efed2 100644 --- a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart +++ b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart @@ -1,18 +1,18 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; bool useIsUserPlaylist(WidgetRef ref, String playlistId) { - final userPlaylistsQuery = useQueries.playlist.ofMineAll(ref); - final me = useQueries.user.me(ref); + final userPlaylistsQuery = ref.watch(favoritePlaylistsProvider); + final me = ref.watch(meProvider); return useMemoized( () => - userPlaylistsQuery.data?.any((e) => + userPlaylistsQuery.asData?.value.items.any((e) => e.id == playlistId && - me.data != null && - e.owner?.id == me.data?.id) ?? + me.value != null && + e.owner?.id == me.asData?.value.id) ?? false, - [userPlaylistsQuery.data, playlistId, me.data], + [userPlaylistsQuery.value, playlistId, me.value], ); } diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/shared/tracks_view/sections/header/flexible_header.dart index 19241dc6..4a704302 100644 --- a/lib/components/shared/tracks_view/sections/header/flexible_header.dart +++ b/lib/components/shared/tracks_view/sections/header/flexible_header.dart @@ -14,7 +14,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; class TrackViewFlexHeader extends HookConsumerWidget { - const TrackViewFlexHeader({Key? key}) : super(key: key); + const TrackViewFlexHeader({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart index 75aa3f61..a16dd750 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -12,7 +12,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; class TrackViewHeaderActions extends HookConsumerWidget { - const TrackViewHeaderActions({Key? key}) : super(key: key); + const TrackViewHeaderActions({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index bae47f12..513f7aaa 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -15,10 +15,10 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final PaletteColor color; final bool compact; const TrackViewHeaderButtons({ - Key? key, + super.key, required this.color, this.compact = false, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/shared/tracks_view/track_view.dart index 4103573c..eb8f6871 100644 --- a/lib/components/shared/tracks_view/track_view.dart +++ b/lib/components/shared/tracks_view/track_view.dart @@ -10,7 +10,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/track_view_b import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; class TrackView extends HookConsumerWidget { - const TrackView({Key? key}) : super(key: key); + const TrackView({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart index 21bbaec7..a1a07f84 100644 --- a/lib/components/shared/tracks_view/track_view_props.dart +++ b/lib/components/shared/tracks_view/track_view_props.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart' hide Page; import 'package:spotify/spotify.dart'; @@ -19,19 +18,6 @@ class PaginationProps { required this.onRefresh, }); - factory PaginationProps.fromQuery( - InfiniteQuery, dynamic, int> query, { - required Future> Function() onFetchAll, - }) { - return PaginationProps( - hasNextPage: query.hasNextPage, - isLoading: query.isLoadingNextPage, - onFetchMore: query.fetchNext, - onFetchAll: onFetchAll, - onRefresh: query.refreshAll, - ); - } - @override operator ==(Object other) { return other is PaginationProps && diff --git a/lib/components/shared/waypoint.dart b/lib/components/shared/waypoint.dart index abd9f98d..08e9088a 100644 --- a/lib/components/shared/waypoint.dart +++ b/lib/components/shared/waypoint.dart @@ -11,12 +11,12 @@ class Waypoint extends HookWidget { final bool isGrid; const Waypoint({ - Key? key, + super.key, required this.controller, this.isGrid = false, this.onTouchEdge, this.child, - }) : super(key: key); + }); @override Widget build(BuildContext context) { diff --git a/lib/extensions/infinite_query.dart b/lib/extensions/infinite_query.dart deleted file mode 100644 index 2181ab3c..00000000 --- a/lib/extensions/infinite_query.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:spotify/spotify.dart'; - -extension FetchAllTracks on InfiniteQuery, dynamic, int> { - Future> fetchAllTracks({ - required Future> Function() getAllTracks, - }) async { - if (pages.isNotEmpty && !hasNextPage) { - return pages.expand((page) => page).toList(); - } - final tracks = await getAllTracks(); - - final numOfPages = (tracks.length / 20).round(); - - final Map> pagedTracks = {}; - - for (var i = 0; i < numOfPages; i++) { - if (i == numOfPages - 1) { - final pageTracks = tracks.sublist(i * 20); - pagedTracks[i] = pageTracks; - break; - } - - final pageTracks = tracks.sublist(i * 20, (i + 1) * 20); - pagedTracks[i] = pageTracks; - } - - for (final group in pagedTracks.entries) { - setPageData(group.key, group.value); - } - - return tracks.toList(); - } -} diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart index f11a1cff..2650b05c 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -1,10 +1,8 @@ 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'; @@ -17,8 +15,6 @@ final linkStream = appLinks.allStringLinkStream.asBroadcastStream(); void useDeepLinking(WidgetRef ref) { // single instance no worries final spotify = ref.watch(spotifyProvider); - final queryClient = useQueryClient(); - final router = ref.watch(routerProvider); useEffect(() { @@ -32,10 +28,7 @@ void useDeepLinking(WidgetRef ref) { case "album": router.push( "/album/${url.pathSegments.last}", - extra: await queryClient.fetchQuery( - "album/${url.pathSegments.last}", - () => spotify.albums.get(url.pathSegments.last), - ), + extra: await spotify.albums.get(url.pathSegments.last), ); break; case "artist": @@ -44,10 +37,7 @@ void useDeepLinking(WidgetRef ref) { case "playlist": router.push( "/playlist/${url.pathSegments.last}", - extra: await queryClient.fetchQuery( - "playlist/${url.pathSegments.last}", - () => spotify.playlists.get(url.pathSegments.last), - ), + extra: await spotify.playlists.get(url.pathSegments.last), ); break; case "track": @@ -78,10 +68,7 @@ void useDeepLinking(WidgetRef ref) { case "spotify:album": await router.push( "/album/$endSegment", - extra: await queryClient.fetchQuery( - "album/$endSegment", - () => spotify.albums.get(endSegment), - ), + extra: await spotify.albums.get(endSegment), ); break; case "spotify:artist": @@ -93,10 +80,7 @@ void useDeepLinking(WidgetRef ref) { case "spotify:playlist": await router.push( "/playlist/$endSegment", - extra: await queryClient.fetchQuery( - "playlist/$endSegment", - () => spotify.playlists.get(endSegment), - ), + extra: await spotify.playlists.get(endSegment), ); break; default: @@ -108,5 +92,5 @@ void useDeepLinking(WidgetRef ref) { mediaStream?.cancel(); subscription.cancel(); }; - }, [spotify, queryClient]); + }, [spotify]); } diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart index f5d11829..3cd55e40 100644 --- a/lib/hooks/configurators/use_endless_playback.dart +++ b/lib/hooks/configurators/use_endless_playback.dart @@ -1,5 +1,4 @@ import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -8,7 +7,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/search.dart'; void useEndlessPlayback(WidgetRef ref) { final auth = ref.watch(AuthenticationNotifier.provider); @@ -18,7 +16,6 @@ void useEndlessPlayback(WidgetRef ref) { final endlessPlayback = ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback)); - final queryClient = useQueryClient(); useEffect( () { @@ -32,16 +29,8 @@ void useEndlessPlayback(WidgetRef ref) { final track = playlist.tracks.last; final query = "${track.name} Radio"; - final pages = await queryClient.fetchInfiniteQueryJob, - dynamic, int, SearchParams>( - job: SearchQueries.queryJob(query), - args: ( - spotify: spotify, - searchType: SearchType.playlist, - query: query - ), - ) ?? - []; + final pages = await spotify.search + .get(query, types: [SearchType.playlist]).first(); final radios = pages .expand((e) => e.items?.toList() ?? []) @@ -94,7 +83,6 @@ void useEndlessPlayback(WidgetRef ref) { [ spotify, playback, - queryClient, playlist.tracks, endlessPlayback, auth, diff --git a/lib/hooks/controllers/use_auto_scroll_controller.dart b/lib/hooks/controllers/use_auto_scroll_controller.dart index 8edfb041..0c7119e4 100644 --- a/lib/hooks/controllers/use_auto_scroll_controller.dart +++ b/lib/hooks/controllers/use_auto_scroll_controller.dart @@ -39,8 +39,8 @@ class _AutoScrollControllerHook extends Hook { this.copyTagsFrom, this.suggestedRowHeight, this.debugLabel, - List? keys, - }) : super(keys: keys); + super.keys, + }); final double initialScrollOffset; final bool keepScrollOffset; diff --git a/lib/hooks/controllers/use_package_info.dart b/lib/hooks/controllers/use_package_info.dart index 9b142ced..b3c05665 100644 --- a/lib/hooks/controllers/use_package_info.dart +++ b/lib/hooks/controllers/use_package_info.dart @@ -44,8 +44,8 @@ class _PackageInfoHook extends Hook { required this.version, required this.buildNumber, this.buildSignature = '', - List? keys, - }) : super(keys: keys); + super.keys, + }); @override HookState> createState() => diff --git a/lib/hooks/controllers/use_sidebarx_controller.dart b/lib/hooks/controllers/use_sidebarx_controller.dart index 5af921b7..a14c3305 100644 --- a/lib/hooks/controllers/use_sidebarx_controller.dart +++ b/lib/hooks/controllers/use_sidebarx_controller.dart @@ -24,8 +24,8 @@ class _SidebarXControllerHook extends Hook { const _SidebarXControllerHook({ required this.selectedIndex, this.extended, - List? keys, - }) : super(keys: keys); + super.keys, + }); final int selectedIndex; final bool? extended; diff --git a/lib/hooks/spotify/use_spotify_infinite_query.dart b/lib/hooks/spotify/use_spotify_infinite_query.dart deleted file mode 100644 index 2063b083..00000000 --- a/lib/hooks/spotify/use_spotify_infinite_query.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -InfiniteQuery - useSpotifyInfiniteQuery( - String queryKey, - FutureOr Function(PageType page, SpotifyApi spotify) queryFn, { - required WidgetRef ref, - required InfiniteQueryNextPage nextPage, - required PageType initialPage, - RetryConfig? retryConfig, - RefreshConfig? refreshConfig, - JsonConfig? jsonConfig, - ValueChanged>? onData, - ValueChanged>? onError, - bool enabled = true, - List? keys, -}) { - final spotify = ref.watch(spotifyProvider); - final query = useInfiniteQuery( - queryKey, - (page) => queryFn(page, spotify), - nextPage: nextPage, - initialPage: initialPage, - retryConfig: retryConfig, - refreshConfig: refreshConfig, - jsonConfig: jsonConfig, - onData: onData, - onError: onError, - enabled: enabled, - keys: keys, - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refreshAll(); - } - }, - ).close; - }, [query]); - - return query; -} diff --git a/lib/hooks/spotify/use_spotify_mutation.dart b/lib/hooks/spotify/use_spotify_mutation.dart deleted file mode 100644 index 637f778f..00000000 --- a/lib/hooks/spotify/use_spotify_mutation.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -Mutation - useSpotifyMutation( - String mutationKey, - Future Function(VariablesType variables, SpotifyApi spotify) - mutationFn, { - required WidgetRef ref, - RetryConfig? retryConfig, - MutationOnDataFn? onData, - MutationOnErrorFn? onError, - MutationOnMutationFn? onMutate, - List? refreshQueries, - List? refreshInfiniteQueries, - List? keys, -}) { - final spotify = ref.watch(spotifyProvider); - final mutation = - useMutation( - mutationKey, - (variables) => mutationFn(variables, spotify), - retryConfig: retryConfig, - onData: onData, - onError: onError, - onMutate: onMutate, - refreshQueries: refreshQueries, - refreshInfiniteQueries: refreshInfiniteQueries, - keys: keys, - ); - - return mutation; -} diff --git a/lib/hooks/spotify/use_spotify_query.dart b/lib/hooks/spotify/use_spotify_query.dart deleted file mode 100644 index 0c79de91..00000000 --- a/lib/hooks/spotify/use_spotify_query.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'dart:async'; - -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/spotify_provider.dart'; - -typedef SpotifyQueryFn = FutureOr Function( - SpotifyApi spotify); - -Query useSpotifyQuery( - final String queryKey, - final SpotifyQueryFn queryFn, { - required WidgetRef ref, - final DataType? initial, - final RetryConfig? retryConfig, - final RefreshConfig? refreshConfig, - final JsonConfig? jsonConfig, - final ValueChanged? onData, - final ValueChanged? onError, - final bool enabled = true, -}) { - final spotify = ref.watch(spotifyProvider); - - final query = useQuery( - queryKey, - () => queryFn(spotify), - initial: initial, - retryConfig: retryConfig, - refreshConfig: refreshConfig, - jsonConfig: jsonConfig, - onData: onData, - onError: onError, - enabled: enabled, - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; -} diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 7aec682a..31eecc99 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -11,6 +11,7 @@ /// Stephan-P@github, SecularSteve@github => Dutch /// doannc2212@github => Vietnamese /// sappho192@github => Korean +library; import 'package:flutter/material.dart'; class L10n { diff --git a/lib/main.dart b/lib/main.dart index 3281f85f..5c100fd3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,14 +1,12 @@ import 'package:catcher_2/catcher_2.dart'; import 'package:dart_discord_rpc/dart_discord_rpc.dart'; import 'package:device_preview/device_preview.dart'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart'; @@ -29,7 +27,6 @@ import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; -import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -75,11 +72,7 @@ Future main(List rawArgs) async { final hiveCacheDir = kIsWeb ? null : (await getApplicationSupportDirectory()).path; - await QueryClient.initialize( - cachePrefix: "oss.krtirtho.spotube", - cacheDir: hiveCacheDir, - connectivity: FlQueryInternetConnectionCheckerAdapter(), - ); + Hive.init(hiveCacheDir); Hive.registerAdapter(SkipSegmentAdapter()); @@ -145,10 +138,7 @@ Future main(List rawArgs) async { orientation: Orientation.portrait, ), builder: (context) { - return QueryClientProvider( - staleDuration: const Duration(minutes: 30), - child: const Spotube(), - ); + return const Spotube(); }, ), ), diff --git a/lib/models/spotify/recommendation_seeds.dart b/lib/models/spotify/recommendation_seeds.dart new file mode 100644 index 00000000..0d874ad6 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.dart @@ -0,0 +1,40 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'recommendation_seeds.freezed.dart'; +part 'recommendation_seeds.g.dart'; + +@freezed +class GeneratePlaylistProviderInput with _$GeneratePlaylistProviderInput { + factory GeneratePlaylistProviderInput({ + Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + required int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target, + }) = _GeneratePlaylistProviderInput; +} + +@freezed +class RecommendationSeeds with _$RecommendationSeeds { + factory RecommendationSeeds({ + num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence, + }) = _RecommendationSeeds; + + factory RecommendationSeeds.fromJson(Map json) => + _$RecommendationSeedsFromJson(json); +} diff --git a/lib/models/spotify/recommendation_seeds.freezed.dart b/lib/models/spotify/recommendation_seeds.freezed.dart new file mode 100644 index 00000000..4cfcce12 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.freezed.dart @@ -0,0 +1,756 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'recommendation_seeds.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +/// @nodoc +mixin _$GeneratePlaylistProviderInput { + Iterable? get seedArtists => throw _privateConstructorUsedError; + Iterable? get seedGenres => throw _privateConstructorUsedError; + Iterable? get seedTracks => throw _privateConstructorUsedError; + int get limit => throw _privateConstructorUsedError; + RecommendationSeeds? get max => throw _privateConstructorUsedError; + RecommendationSeeds? get min => throw _privateConstructorUsedError; + RecommendationSeeds? get target => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $GeneratePlaylistProviderInputCopyWith + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $GeneratePlaylistProviderInputCopyWith<$Res> { + factory $GeneratePlaylistProviderInputCopyWith( + GeneratePlaylistProviderInput value, + $Res Function(GeneratePlaylistProviderInput) then) = + _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + GeneratePlaylistProviderInput>; + @useResult + $Res call( + {Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target}); + + $RecommendationSeedsCopyWith<$Res>? get max; + $RecommendationSeedsCopyWith<$Res>? get min; + $RecommendationSeedsCopyWith<$Res>? get target; +} + +/// @nodoc +class _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + $Val extends GeneratePlaylistProviderInput> + implements $GeneratePlaylistProviderInputCopyWith<$Res> { + _$GeneratePlaylistProviderInputCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? seedArtists = freezed, + Object? seedGenres = freezed, + Object? seedTracks = freezed, + Object? limit = null, + Object? max = freezed, + Object? min = freezed, + Object? target = freezed, + }) { + return _then(_value.copyWith( + seedArtists: freezed == seedArtists + ? _value.seedArtists + : seedArtists // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedGenres: freezed == seedGenres + ? _value.seedGenres + : seedGenres // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedTracks: freezed == seedTracks + ? _value.seedTracks + : seedTracks // ignore: cast_nullable_to_non_nullable + as Iterable?, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + max: freezed == max + ? _value.max + : max // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + min: freezed == min + ? _value.min + : min // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + target: freezed == target + ? _value.target + : target // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get max { + if (_value.max == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.max!, (value) { + return _then(_value.copyWith(max: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get min { + if (_value.min == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.min!, (value) { + return _then(_value.copyWith(min: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $RecommendationSeedsCopyWith<$Res>? get target { + if (_value.target == null) { + return null; + } + + return $RecommendationSeedsCopyWith<$Res>(_value.target!, (value) { + return _then(_value.copyWith(target: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$GeneratePlaylistProviderInputImplCopyWith<$Res> + implements $GeneratePlaylistProviderInputCopyWith<$Res> { + factory _$$GeneratePlaylistProviderInputImplCopyWith( + _$GeneratePlaylistProviderInputImpl value, + $Res Function(_$GeneratePlaylistProviderInputImpl) then) = + __$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + int limit, + RecommendationSeeds? max, + RecommendationSeeds? min, + RecommendationSeeds? target}); + + @override + $RecommendationSeedsCopyWith<$Res>? get max; + @override + $RecommendationSeedsCopyWith<$Res>? get min; + @override + $RecommendationSeedsCopyWith<$Res>? get target; +} + +/// @nodoc +class __$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res> + extends _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + _$GeneratePlaylistProviderInputImpl> + implements _$$GeneratePlaylistProviderInputImplCopyWith<$Res> { + __$$GeneratePlaylistProviderInputImplCopyWithImpl( + _$GeneratePlaylistProviderInputImpl _value, + $Res Function(_$GeneratePlaylistProviderInputImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? seedArtists = freezed, + Object? seedGenres = freezed, + Object? seedTracks = freezed, + Object? limit = null, + Object? max = freezed, + Object? min = freezed, + Object? target = freezed, + }) { + return _then(_$GeneratePlaylistProviderInputImpl( + seedArtists: freezed == seedArtists + ? _value.seedArtists + : seedArtists // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedGenres: freezed == seedGenres + ? _value.seedGenres + : seedGenres // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedTracks: freezed == seedTracks + ? _value.seedTracks + : seedTracks // ignore: cast_nullable_to_non_nullable + as Iterable?, + limit: null == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int, + max: freezed == max + ? _value.max + : max // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + min: freezed == min + ? _value.min + : min // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + target: freezed == target + ? _value.target + : target // ignore: cast_nullable_to_non_nullable + as RecommendationSeeds?, + )); + } +} + +/// @nodoc + +class _$GeneratePlaylistProviderInputImpl + implements _GeneratePlaylistProviderInput { + _$GeneratePlaylistProviderInputImpl( + {this.seedArtists, + this.seedGenres, + this.seedTracks, + required this.limit, + this.max, + this.min, + this.target}); + + @override + final Iterable? seedArtists; + @override + final Iterable? seedGenres; + @override + final Iterable? seedTracks; + @override + final int limit; + @override + final RecommendationSeeds? max; + @override + final RecommendationSeeds? min; + @override + final RecommendationSeeds? target; + + @override + String toString() { + return 'GeneratePlaylistProviderInput(seedArtists: $seedArtists, seedGenres: $seedGenres, seedTracks: $seedTracks, limit: $limit, max: $max, min: $min, target: $target)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GeneratePlaylistProviderInputImpl && + const DeepCollectionEquality() + .equals(other.seedArtists, seedArtists) && + const DeepCollectionEquality() + .equals(other.seedGenres, seedGenres) && + const DeepCollectionEquality() + .equals(other.seedTracks, seedTracks) && + (identical(other.limit, limit) || other.limit == limit) && + (identical(other.max, max) || other.max == max) && + (identical(other.min, min) || other.min == min) && + (identical(other.target, target) || other.target == target)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(seedArtists), + const DeepCollectionEquality().hash(seedGenres), + const DeepCollectionEquality().hash(seedTracks), + limit, + max, + min, + target); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$GeneratePlaylistProviderInputImplCopyWith< + _$GeneratePlaylistProviderInputImpl> + get copyWith => __$$GeneratePlaylistProviderInputImplCopyWithImpl< + _$GeneratePlaylistProviderInputImpl>(this, _$identity); +} + +abstract class _GeneratePlaylistProviderInput + implements GeneratePlaylistProviderInput { + factory _GeneratePlaylistProviderInput( + {final Iterable? seedArtists, + final Iterable? seedGenres, + final Iterable? seedTracks, + required final int limit, + final RecommendationSeeds? max, + final RecommendationSeeds? min, + final RecommendationSeeds? target}) = _$GeneratePlaylistProviderInputImpl; + + @override + Iterable? get seedArtists; + @override + Iterable? get seedGenres; + @override + Iterable? get seedTracks; + @override + int get limit; + @override + RecommendationSeeds? get max; + @override + RecommendationSeeds? get min; + @override + RecommendationSeeds? get target; + @override + @JsonKey(ignore: true) + _$$GeneratePlaylistProviderInputImplCopyWith< + _$GeneratePlaylistProviderInputImpl> + get copyWith => throw _privateConstructorUsedError; +} + +RecommendationSeeds _$RecommendationSeedsFromJson(Map json) { + return _RecommendationSeeds.fromJson(json); +} + +/// @nodoc +mixin _$RecommendationSeeds { + num? get acousticness => throw _privateConstructorUsedError; + num? get danceability => throw _privateConstructorUsedError; + @JsonKey(name: "duration_ms") + num? get durationMs => throw _privateConstructorUsedError; + num? get energy => throw _privateConstructorUsedError; + num? get instrumentalness => throw _privateConstructorUsedError; + num? get key => throw _privateConstructorUsedError; + num? get liveness => throw _privateConstructorUsedError; + num? get loudness => throw _privateConstructorUsedError; + num? get mode => throw _privateConstructorUsedError; + num? get popularity => throw _privateConstructorUsedError; + num? get speechiness => throw _privateConstructorUsedError; + num? get tempo => throw _privateConstructorUsedError; + @JsonKey(name: "time_signature") + num? get timeSignature => throw _privateConstructorUsedError; + num? get valence => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $RecommendationSeedsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RecommendationSeedsCopyWith<$Res> { + factory $RecommendationSeedsCopyWith( + RecommendationSeeds value, $Res Function(RecommendationSeeds) then) = + _$RecommendationSeedsCopyWithImpl<$Res, RecommendationSeeds>; + @useResult + $Res call( + {num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence}); +} + +/// @nodoc +class _$RecommendationSeedsCopyWithImpl<$Res, $Val extends RecommendationSeeds> + implements $RecommendationSeedsCopyWith<$Res> { + _$RecommendationSeedsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? acousticness = freezed, + Object? danceability = freezed, + Object? durationMs = freezed, + Object? energy = freezed, + Object? instrumentalness = freezed, + Object? key = freezed, + Object? liveness = freezed, + Object? loudness = freezed, + Object? mode = freezed, + Object? popularity = freezed, + Object? speechiness = freezed, + Object? tempo = freezed, + Object? timeSignature = freezed, + Object? valence = freezed, + }) { + return _then(_value.copyWith( + acousticness: freezed == acousticness + ? _value.acousticness + : acousticness // ignore: cast_nullable_to_non_nullable + as num?, + danceability: freezed == danceability + ? _value.danceability + : danceability // ignore: cast_nullable_to_non_nullable + as num?, + durationMs: freezed == durationMs + ? _value.durationMs + : durationMs // ignore: cast_nullable_to_non_nullable + as num?, + energy: freezed == energy + ? _value.energy + : energy // ignore: cast_nullable_to_non_nullable + as num?, + instrumentalness: freezed == instrumentalness + ? _value.instrumentalness + : instrumentalness // ignore: cast_nullable_to_non_nullable + as num?, + key: freezed == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as num?, + liveness: freezed == liveness + ? _value.liveness + : liveness // ignore: cast_nullable_to_non_nullable + as num?, + loudness: freezed == loudness + ? _value.loudness + : loudness // ignore: cast_nullable_to_non_nullable + as num?, + mode: freezed == mode + ? _value.mode + : mode // ignore: cast_nullable_to_non_nullable + as num?, + popularity: freezed == popularity + ? _value.popularity + : popularity // ignore: cast_nullable_to_non_nullable + as num?, + speechiness: freezed == speechiness + ? _value.speechiness + : speechiness // ignore: cast_nullable_to_non_nullable + as num?, + tempo: freezed == tempo + ? _value.tempo + : tempo // ignore: cast_nullable_to_non_nullable + as num?, + timeSignature: freezed == timeSignature + ? _value.timeSignature + : timeSignature // ignore: cast_nullable_to_non_nullable + as num?, + valence: freezed == valence + ? _value.valence + : valence // ignore: cast_nullable_to_non_nullable + as num?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$RecommendationSeedsImplCopyWith<$Res> + implements $RecommendationSeedsCopyWith<$Res> { + factory _$$RecommendationSeedsImplCopyWith(_$RecommendationSeedsImpl value, + $Res Function(_$RecommendationSeedsImpl) then) = + __$$RecommendationSeedsImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {num? acousticness, + num? danceability, + @JsonKey(name: "duration_ms") num? durationMs, + num? energy, + num? instrumentalness, + num? key, + num? liveness, + num? loudness, + num? mode, + num? popularity, + num? speechiness, + num? tempo, + @JsonKey(name: "time_signature") num? timeSignature, + num? valence}); +} + +/// @nodoc +class __$$RecommendationSeedsImplCopyWithImpl<$Res> + extends _$RecommendationSeedsCopyWithImpl<$Res, _$RecommendationSeedsImpl> + implements _$$RecommendationSeedsImplCopyWith<$Res> { + __$$RecommendationSeedsImplCopyWithImpl(_$RecommendationSeedsImpl _value, + $Res Function(_$RecommendationSeedsImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? acousticness = freezed, + Object? danceability = freezed, + Object? durationMs = freezed, + Object? energy = freezed, + Object? instrumentalness = freezed, + Object? key = freezed, + Object? liveness = freezed, + Object? loudness = freezed, + Object? mode = freezed, + Object? popularity = freezed, + Object? speechiness = freezed, + Object? tempo = freezed, + Object? timeSignature = freezed, + Object? valence = freezed, + }) { + return _then(_$RecommendationSeedsImpl( + acousticness: freezed == acousticness + ? _value.acousticness + : acousticness // ignore: cast_nullable_to_non_nullable + as num?, + danceability: freezed == danceability + ? _value.danceability + : danceability // ignore: cast_nullable_to_non_nullable + as num?, + durationMs: freezed == durationMs + ? _value.durationMs + : durationMs // ignore: cast_nullable_to_non_nullable + as num?, + energy: freezed == energy + ? _value.energy + : energy // ignore: cast_nullable_to_non_nullable + as num?, + instrumentalness: freezed == instrumentalness + ? _value.instrumentalness + : instrumentalness // ignore: cast_nullable_to_non_nullable + as num?, + key: freezed == key + ? _value.key + : key // ignore: cast_nullable_to_non_nullable + as num?, + liveness: freezed == liveness + ? _value.liveness + : liveness // ignore: cast_nullable_to_non_nullable + as num?, + loudness: freezed == loudness + ? _value.loudness + : loudness // ignore: cast_nullable_to_non_nullable + as num?, + mode: freezed == mode + ? _value.mode + : mode // ignore: cast_nullable_to_non_nullable + as num?, + popularity: freezed == popularity + ? _value.popularity + : popularity // ignore: cast_nullable_to_non_nullable + as num?, + speechiness: freezed == speechiness + ? _value.speechiness + : speechiness // ignore: cast_nullable_to_non_nullable + as num?, + tempo: freezed == tempo + ? _value.tempo + : tempo // ignore: cast_nullable_to_non_nullable + as num?, + timeSignature: freezed == timeSignature + ? _value.timeSignature + : timeSignature // ignore: cast_nullable_to_non_nullable + as num?, + valence: freezed == valence + ? _value.valence + : valence // ignore: cast_nullable_to_non_nullable + as num?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$RecommendationSeedsImpl implements _RecommendationSeeds { + _$RecommendationSeedsImpl( + {this.acousticness, + this.danceability, + @JsonKey(name: "duration_ms") this.durationMs, + this.energy, + this.instrumentalness, + this.key, + this.liveness, + this.loudness, + this.mode, + this.popularity, + this.speechiness, + this.tempo, + @JsonKey(name: "time_signature") this.timeSignature, + this.valence}); + + factory _$RecommendationSeedsImpl.fromJson(Map json) => + _$$RecommendationSeedsImplFromJson(json); + + @override + final num? acousticness; + @override + final num? danceability; + @override + @JsonKey(name: "duration_ms") + final num? durationMs; + @override + final num? energy; + @override + final num? instrumentalness; + @override + final num? key; + @override + final num? liveness; + @override + final num? loudness; + @override + final num? mode; + @override + final num? popularity; + @override + final num? speechiness; + @override + final num? tempo; + @override + @JsonKey(name: "time_signature") + final num? timeSignature; + @override + final num? valence; + + @override + String toString() { + return 'RecommendationSeeds(acousticness: $acousticness, danceability: $danceability, durationMs: $durationMs, energy: $energy, instrumentalness: $instrumentalness, key: $key, liveness: $liveness, loudness: $loudness, mode: $mode, popularity: $popularity, speechiness: $speechiness, tempo: $tempo, timeSignature: $timeSignature, valence: $valence)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$RecommendationSeedsImpl && + (identical(other.acousticness, acousticness) || + other.acousticness == acousticness) && + (identical(other.danceability, danceability) || + other.danceability == danceability) && + (identical(other.durationMs, durationMs) || + other.durationMs == durationMs) && + (identical(other.energy, energy) || other.energy == energy) && + (identical(other.instrumentalness, instrumentalness) || + other.instrumentalness == instrumentalness) && + (identical(other.key, key) || other.key == key) && + (identical(other.liveness, liveness) || + other.liveness == liveness) && + (identical(other.loudness, loudness) || + other.loudness == loudness) && + (identical(other.mode, mode) || other.mode == mode) && + (identical(other.popularity, popularity) || + other.popularity == popularity) && + (identical(other.speechiness, speechiness) || + other.speechiness == speechiness) && + (identical(other.tempo, tempo) || other.tempo == tempo) && + (identical(other.timeSignature, timeSignature) || + other.timeSignature == timeSignature) && + (identical(other.valence, valence) || other.valence == valence)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + acousticness, + danceability, + durationMs, + energy, + instrumentalness, + key, + liveness, + loudness, + mode, + popularity, + speechiness, + tempo, + timeSignature, + valence); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$RecommendationSeedsImplCopyWith<_$RecommendationSeedsImpl> get copyWith => + __$$RecommendationSeedsImplCopyWithImpl<_$RecommendationSeedsImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$RecommendationSeedsImplToJson( + this, + ); + } +} + +abstract class _RecommendationSeeds implements RecommendationSeeds { + factory _RecommendationSeeds( + {final num? acousticness, + final num? danceability, + @JsonKey(name: "duration_ms") final num? durationMs, + final num? energy, + final num? instrumentalness, + final num? key, + final num? liveness, + final num? loudness, + final num? mode, + final num? popularity, + final num? speechiness, + final num? tempo, + @JsonKey(name: "time_signature") final num? timeSignature, + final num? valence}) = _$RecommendationSeedsImpl; + + factory _RecommendationSeeds.fromJson(Map json) = + _$RecommendationSeedsImpl.fromJson; + + @override + num? get acousticness; + @override + num? get danceability; + @override + @JsonKey(name: "duration_ms") + num? get durationMs; + @override + num? get energy; + @override + num? get instrumentalness; + @override + num? get key; + @override + num? get liveness; + @override + num? get loudness; + @override + num? get mode; + @override + num? get popularity; + @override + num? get speechiness; + @override + num? get tempo; + @override + @JsonKey(name: "time_signature") + num? get timeSignature; + @override + num? get valence; + @override + @JsonKey(ignore: true) + _$$RecommendationSeedsImplCopyWith<_$RecommendationSeedsImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/spotify/recommendation_seeds.g.dart b/lib/models/spotify/recommendation_seeds.g.dart new file mode 100644 index 00000000..bdfa3a07 --- /dev/null +++ b/lib/models/spotify/recommendation_seeds.g.dart @@ -0,0 +1,45 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'recommendation_seeds.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson( + Map json) => + _$RecommendationSeedsImpl( + acousticness: json['acousticness'] as num?, + danceability: json['danceability'] as num?, + durationMs: json['duration_ms'] as num?, + energy: json['energy'] as num?, + instrumentalness: json['instrumentalness'] as num?, + key: json['key'] as num?, + liveness: json['liveness'] as num?, + loudness: json['loudness'] as num?, + mode: json['mode'] as num?, + popularity: json['popularity'] as num?, + speechiness: json['speechiness'] as num?, + tempo: json['tempo'] as num?, + timeSignature: json['time_signature'] as num?, + valence: json['valence'] as num?, + ); + +Map _$$RecommendationSeedsImplToJson( + _$RecommendationSeedsImpl instance) => + { + 'acousticness': instance.acousticness, + 'danceability': instance.danceability, + 'duration_ms': instance.durationMs, + 'energy': instance.energy, + 'instrumentalness': instance.instrumentalness, + 'key': instance.key, + 'liveness': instance.liveness, + 'loudness': instance.loudness, + 'mode': instance.mode, + 'popularity': instance.popularity, + 'speechiness': instance.speechiness, + 'tempo': instance.tempo, + 'time_signature': instance.timeSignature, + 'valence': instance.valence, + }; diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 4578aea2..fac0a6a6 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -1,15 +1,10 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/infinite_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class AlbumPage extends HookConsumerWidget { @@ -21,26 +16,10 @@ class AlbumPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final tracksQuery = useQueries.album.tracksOf(ref, album); - - final tracks = useMemoized(() { - return tracksQuery.pages.expand((element) => element).toList(); - }, [tracksQuery.pages]); - - final client = useQueryClient(); - - final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!); - final isLiked = albumIsSaved.data ?? false; - - final toggleAlbumLike = useMutations.album.toggleFavorite( - ref, - album.id!, - refreshQueries: [albumIsSaved.key], - onData: (_, __) async { - await client.refreshInfiniteQueryAllPages("current-user-albums"); - }, - ); + final tracks = ref.watch(albumTracksProvider(album)); + final tracksNotifier = ref.watch(albumTracksProvider(album).notifier); + final favoriteAlbumsNotifier = ref.watch(favoriteAlbumsProvider.notifier); + final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); return InheritedTrackView( collectionId: album.id!, @@ -51,29 +30,33 @@ class AlbumPage extends HookConsumerWidget { title: album.name!, description: "${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}", - tracks: tracks, - pagination: PaginationProps.fromQuery( - tracksQuery, - onFetchAll: () { - return tracksQuery.fetchAllTracks(getAllTracks: () async { - final res = await spotify.albums.tracks(album.id!).all(); - - return res - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(); - }); + tracks: tracks.asData?.value.items ?? [], + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + isLoading: tracks.isLoadingNextPage, + onFetchMore: () async { + await tracksNotifier.fetchMore(); + }, + onFetchAll: () async { + return tracksNotifier.fetchAll(); + }, + onRefresh: () async { + ref.invalidate(albumTracksProvider(album)); }, ), routePath: "/album/${album.id}", shareUrl: album.externalUrls!.spotify!, - isLiked: isLiked, - onHeart: albumIsSaved.hasData - ? () async { - await toggleAlbumLike.mutate(isLiked); + isLiked: isSavedAlbum.value ?? false, + onHeart: isSavedAlbum.value == null + ? null + : () async { + if (isSavedAlbum.value!) { + await favoriteAlbumsNotifier.removeFavorites([album.id!]); + } else { + await favoriteAlbumsNotifier.addFavorites([album.id!]); + } return null; - } - : null, + }, child: const TrackView(), ); } diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index d511cb97..c153f0af 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -12,19 +12,19 @@ import 'package:spotube/pages/artist/section/footer.dart'; import 'package:spotube/pages/artist/section/header.dart'; import 'package:spotube/pages/artist/section/related_artists.dart'; import 'package:spotube/pages/artist/section/top_tracks.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPage extends HookConsumerWidget { final String artistId; final logger = getLogger(ArtistPage); - ArtistPage(this.artistId, {Key? key}) : super(key: key); + ArtistPage(this.artistId, {super.key}); @override Widget build(BuildContext context, ref) { final scrollController = useScrollController(); final theme = Theme.of(context); - final artistQuery = useQueries.artist.get(ref, artistId); + final artistQuery = ref.watch(artistProvider(artistId)); return SafeArea( bottom: false, @@ -35,7 +35,7 @@ class ArtistPage extends HookConsumerWidget { ), extendBodyBehindAppBar: true, body: Builder(builder: (context) { - if (artistQuery.hasError && artistQuery.data == null) { + if (artistQuery.hasError && artistQuery.value == null) { return Center(child: Text(artistQuery.error.toString())); } return Skeletonizer( @@ -66,11 +66,11 @@ class ArtistPage extends HookConsumerWidget { SliverSafeArea( sliver: ArtistPageRelatedArtists(artistId: artistId), ), - if (artistQuery.data != null) + if (artistQuery.value != null) SliverSafeArea( top: false, sliver: SliverToBoxAdapter( - child: ArtistPageFooter(artist: artistQuery.data!), + child: ArtistPageFooter(artist: artistQuery.value!), ), ), ], diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index b01ef705..ac166252 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -5,13 +5,13 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; -class ArtistPageFooter extends HookConsumerWidget { +class ArtistPageFooter extends ConsumerWidget { final Artist artist; - const ArtistPageFooter({Key? key, required this.artist}) : super(key: key); + const ArtistPageFooter({super.key, required this.artist}); @override Widget build(BuildContext context, ref) { @@ -22,8 +22,9 @@ class ArtistPageFooter extends HookConsumerWidget { artist.images, placeholder: ImagePlaceholder.artist, ); - final summary = useQueries.artist.wikipediaSummary(artist); - if (summary.hasError || !summary.hasData) return const SizedBox.shrink(); + final summary = ref.watch(artistWikipediaSummaryProvider(artist)); + if (summary.value == null) return const SizedBox.shrink(); + return Container( margin: const EdgeInsets.all(16), padding: mediaQuery.smAndDown @@ -38,9 +39,9 @@ class ArtistPageFooter extends HookConsumerWidget { BlendMode.darken, ), image: UniversalImage.imageProvider( - summary.data!.thumbnail?.source_ ?? artistImage, - height: summary.data!.thumbnail?.height.toDouble(), - width: summary.data!.thumbnail?.width.toDouble(), + summary.value!.thumbnail?.source_ ?? artistImage, + height: summary.value!.thumbnail?.height.toDouble(), + width: summary.value!.thumbnail?.width.toDouble(), ), fit: BoxFit.cover, alignment: Alignment.center, @@ -69,7 +70,7 @@ class ArtistPageFooter extends HookConsumerWidget { ), const TextSpan(text: '\n\n'), TextSpan( - text: summary.data!.extract, + text: summary.value!.extract, ), TextSpan( text: '\n...read more at wikipedia', @@ -81,7 +82,7 @@ class ArtistPageFooter extends HookConsumerWidget { recognizer: TapGestureRecognizer() ..onTap = () async { await launchUrlString( - "http://en.wikipedia.org/wiki?curid=${summary.data?.pageid}", + "http://en.wikipedia.org/wiki?curid=${summary.asData?.value?.pageid}", ); }, ), diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 7cee7a01..7756da15 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -1,11 +1,8 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package: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'; @@ -14,20 +11,18 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistPageHeader extends HookConsumerWidget { final String artistId; - const ArtistPageHeader({Key? key, required this.artistId}) : super(key: key); + const ArtistPageHeader({super.key, required this.artistId}); @override Widget build(BuildContext context, ref) { - final queryClient = useQueryClient(); - final artistQuery = useQueries.artist.get(ref, artistId); - final artist = artistQuery.data ?? FakeData.artist; + final artistQuery = ref.watch(artistProvider(artistId)); + final artist = artistQuery.value ?? FakeData.artist; final scaffoldMessenger = ScaffoldMessenger.of(context); final mediaQuery = MediaQuery.of(context); @@ -43,7 +38,6 @@ class ArtistPageHeader extends HookConsumerWidget { xxl: textTheme.titleMedium, ); - final spotify = ref.read(spotifyProvider); final auth = ref.watch(AuthenticationNotifier.provider); final blacklist = ref.watch(BlackListNotifier.provider); final isBlackListed = blacklist.contains( @@ -143,53 +137,41 @@ class ArtistPageHeader extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (auth != null) - HookBuilder( - builder: (context) { - final isFollowingQuery = - useQueries.artist.doIFollow(ref, artistId); + Consumer( + builder: (context, ref, _) { + final isFollowingQuery = ref + .watch(artistIsFollowingProvider(artist.id!)); + final followingArtistNotifier = + ref.watch(followedArtistsProvider.notifier); - final followUnfollow = useCallback(() async { - try { - isFollowingQuery.data! - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], + return switch (isFollowingQuery) { + AsyncData(value: final following) => Builder( + builder: (context) { + if (following) { + return OutlinedButton( + onPressed: () async { + await followingArtistNotifier + .removeArtists([artist.id!]); + }, + child: Text(context.l10n.following), ); - await isFollowingQuery.refresh(); + } - queryClient.refreshInfiniteQueryAllPages( - "user-following-artists"); - } finally { - queryClient.refreshQuery( - "user-follows-artists-query/$artistId", - ); - } - }, [isFollowingQuery]); - - if (isFollowingQuery.isLoading || - !isFollowingQuery.hasData) { - return const SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator(), - ); - } - - if (isFollowingQuery.data!) { - return OutlinedButton( - onPressed: followUnfollow, - child: Text(context.l10n.following), - ); - } - - return FilledButton( - onPressed: followUnfollow, - child: Text(context.l10n.follow), - ); + return FilledButton( + onPressed: () async { + await followingArtistNotifier + .saveArtists([artist.id!]); + }, + child: Text(context.l10n.follow), + ); + }, + ), + AsyncError() => const SizedBox(), + _ => const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator(), + ) + }; }, ), const SizedBox(width: 5), diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart index 2938c084..7fc48ded 100644 --- a/lib/pages/artist/section/related_artists.dart +++ b/lib/pages/artist/section/related_artists.dart @@ -1,49 +1,45 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/artist/artist_card.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; -class ArtistPageRelatedArtists extends HookConsumerWidget { +class ArtistPageRelatedArtists extends ConsumerWidget { final String artistId; const ArtistPageRelatedArtists({ - Key? key, + super.key, required this.artistId, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final relatedArtists = useQueries.artist.relatedArtistsOf( - ref, - artistId, - ); + final relatedArtists = ref.watch(relatedArtistsProvider(artistId)); - if (relatedArtists.isLoading || !relatedArtists.hasData) { - return const SliverToBoxAdapter( - child: Center(child: CircularProgressIndicator())); - } else if (relatedArtists.hasError) { - return SliverToBoxAdapter( - child: Center( - child: Text(relatedArtists.error.toString()), + return switch (relatedArtists) { + AsyncData(value: final artists) => SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverGrid.builder( + itemCount: artists.length, + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: 250, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 0.8, + ), + itemBuilder: (context, index) { + final artist = artists.elementAt(index); + return ArtistCard(artist); + }, + ), ), - ); - } - - return SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - sliver: SliverGrid.builder( - itemCount: relatedArtists.data!.length, - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: 250, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: 0.8, + AsyncError(:final error) => SliverToBoxAdapter( + child: Center( + child: Text(error.toString()), + ), ), - itemBuilder: (context, index) { - final artist = relatedArtists.data!.elementAt(index); - return ArtistCard(artist); - }, - ), - ); + _ => const SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ), + }; } } diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 771757b9..9ad2b0db 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -7,12 +7,11 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPageTopTracks extends HookConsumerWidget { final String artistId; - const ArtistPageTopTracks({Key? key, required this.artistId}) - : super(key: key); + const ArtistPageTopTracks({super.key, required this.artistId}); @override Widget build(BuildContext context, ref) { @@ -21,13 +20,10 @@ class ArtistPageTopTracks extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final topTracksQuery = useQueries.artist.topTracksOf( - ref, - artistId, - ); + final topTracksQuery = ref.watch(artistTopTracksProvider(artistId)); final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.data ?? [], + topTracksQuery.value ?? [], ); if (topTracksQuery.hasError) { @@ -39,7 +35,7 @@ class ArtistPageTopTracks extends HookConsumerWidget { } final topTracks = - topTracksQuery.data ?? List.generate(10, (index) => FakeData.track); + topTracksQuery.value ?? List.generate(10, (index) => FakeData.track); void playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index c2cc3695..9c061091 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -9,7 +9,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; class DesktopLoginPage extends HookConsumerWidget { - const DesktopLoginPage({Key? key}) : super(key: key); + const DesktopLoginPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index 24373e75..e6a4cf9a 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -12,7 +12,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class LoginTutorial extends ConsumerWidget { - const LoginTutorial({Key? key}) : super(key: key); + const LoginTutorial({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index bfb0843c..d80b4513 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -1,5 +1,3 @@ -import 'dart:ui'; - import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; @@ -12,7 +10,7 @@ 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:spotube/provider/spotify/spotify.dart'; import 'package:collection/collection.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; @@ -22,23 +20,10 @@ class GenrePlaylistsPage extends HookConsumerWidget { @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.empty(); - }, - ).toList(), - [playlistsQuery.pages], - ); - final mediaQuery = MediaQuery.of(context); - + final playlists = ref.watch(categoryPlaylistsProvider(category.id!)); + final playlistsNotifier = + ref.read(categoryPlaylistsProvider(category.id!).notifier); final scrollController = useScrollController(); return Scaffold( @@ -109,7 +94,7 @@ class GenrePlaylistsPage extends HookConsumerWidget { padding: EdgeInsets.symmetric( horizontal: mediaQuery.mdAndDown ? 12 : 24, ), - sliver: playlists.isEmpty + sliver: playlists.asData?.value.items.isNotEmpty != true ? Skeletonizer.sliver( child: SliverToBoxAdapter( child: Wrap( @@ -129,12 +114,14 @@ class GenrePlaylistsPage extends HookConsumerWidget { crossAxisSpacing: 12, mainAxisSpacing: 12, ), - itemCount: playlists.length + 1, + itemCount: + (playlists.asData?.value.items.length ?? 0) + 1, itemBuilder: (context, index) { - final playlist = playlists.elementAtOrNull(index); + final playlist = playlists.asData?.value.items + .elementAtOrNull(index); if (playlist == null) { - if (!playlistsQuery.hasNextPage) { + if (playlists.asData?.value.hasMore == false) { return const SizedBox.shrink(); } return Skeletonizer( @@ -142,11 +129,7 @@ class GenrePlaylistsPage extends HookConsumerWidget { child: Waypoint( controller: scrollController, isGrid: true, - onTouchEdge: () async { - if (playlistsQuery.hasNextPage) { - await playlistsQuery.fetchNext(); - } - }, + onTouchEdge: playlistsNotifier.fetchMore, child: PlaylistCard(FakeData.playlist), ), ); diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index 17a67beb..ed6c2835 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -5,14 +5,11 @@ 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'; +import 'package:spotube/provider/spotify/spotify.dart'; class GenrePage extends HookConsumerWidget { const GenrePage({super.key}); @@ -21,13 +18,7 @@ class GenrePage extends HookConsumerWidget { 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 ?? []; + final categories = ref.watch(categoriesProvider); final mediaQuery = MediaQuery.of(context); @@ -48,9 +39,9 @@ class GenrePage extends HookConsumerWidget { crossAxisSpacing: 12, mainAxisSpacing: 12, ), - itemCount: categories.length, + itemCount: categories.value!.length, itemBuilder: (context, index) { - final category = categories[index]; + final category = categories.value![index]; final gradient = gradients[Random().nextInt(gradients.length)]; return InkWell( borderRadius: BorderRadius.circular(8), diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 312ca7f9..ed297065 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -11,7 +11,7 @@ import 'package:spotube/components/home/sections/new_releases.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; class HomePage extends HookConsumerWidget { - const HomePage({Key? key}) : super(key: key); + const HomePage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index 4280328f..b6aeef2e 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -10,7 +10,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; class LastFMLoginPage extends HookConsumerWidget { - const LastFMLoginPage({Key? key}) : super(key: key); + const LastFMLoginPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index b6b88656..ccdb6a35 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -12,7 +12,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class LibraryPage extends HookConsumerWidget { - const LibraryPage({Key? key}) : super(key: key); + const LibraryPage({super.key}); @override Widget build(BuildContext context, ref) { final downloadingCount = ref.watch(downloadManagerProvider).$downloadCount; diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 802b28d3..642ceb6c 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -15,16 +15,16 @@ import 'package:spotube/components/shared/image/universal_image.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/pages/library/playlist_generate/playlist_generate_result.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); class PlaylistGeneratorPage extends HookConsumerWidget { - const PlaylistGeneratorPage({Key? key}) : super(key: key); + const PlaylistGeneratorPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -34,7 +34,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final textTheme = theme.textTheme; final preferences = ref.watch(userPreferencesProvider); - final genresCollection = useQueries.category.genreSeeds(ref); + final genresCollection = ref.watch(categoryGenresProvider); final limit = useValueNotifier(10); final market = useValueNotifier(preferences.recommendationMarket); @@ -50,22 +50,9 @@ class PlaylistGeneratorPage extends HookConsumerWidget { 5 - genres.value.length - artists.value.length - tracks.value.length; // Dial (int 0-1) attributes - final acousticness = useState(zeroValues); - final danceability = useState(zeroValues); - final energy = useState(zeroValues); - final instrumentalness = useState(zeroValues); - final key = useState(zeroValues); - final liveness = useState(zeroValues); - final loudness = useState(zeroValues); - final popularity = useState(zeroValues); - final speechiness = useState(zeroValues); - final valence = useState(zeroValues); - - // Field editable attributes - final tempo = useState(zeroValues); - final durationMs = useState(zeroValues); - final mode = useState(zeroValues); - final timeSignature = useState(zeroValues); + final min = useState(RecommendationSeeds()); + final max = useState(RecommendationSeeds()); + final target = useState(RecommendationSeeds()); final artistAutoComplete = SeedsMultiAutocomplete( seeds: artists, @@ -203,7 +190,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ); final genreSelector = MultiSelectField( - options: genresCollection.data ?? [], + options: genresCollection.value ?? [], selectedOptions: genres.value, getValueForOption: (option) => option, onSelected: (value) { @@ -355,88 +342,213 @@ class PlaylistGeneratorPage extends HookConsumerWidget { const SizedBox(height: 16), RecommendationAttributeDials( title: Text(context.l10n.acousticness), - values: acousticness.value, + values: ( + target: target.value.acousticness?.toDouble() ?? 0, + min: min.value.acousticness?.toDouble() ?? 0, + max: max.value.acousticness?.toDouble() ?? 0, + ), onChanged: (value) { - acousticness.value = value; + target.value = target.value.copyWith( + acousticness: value.target, + ); + min.value = min.value.copyWith( + acousticness: value.min, + ); + max.value = max.value.copyWith( + acousticness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.danceability), - values: danceability.value, + values: ( + target: target.value.danceability?.toDouble() ?? 0, + min: min.value.danceability?.toDouble() ?? 0, + max: max.value.danceability?.toDouble() ?? 0, + ), onChanged: (value) { - danceability.value = value; + target.value = target.value.copyWith( + danceability: value.target, + ); + min.value = min.value.copyWith( + danceability: value.min, + ); + max.value = max.value.copyWith( + danceability: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.energy), - values: energy.value, + values: ( + target: target.value.energy?.toDouble() ?? 0, + min: min.value.energy?.toDouble() ?? 0, + max: max.value.energy?.toDouble() ?? 0, + ), onChanged: (value) { - energy.value = value; + target.value = target.value.copyWith( + energy: value.target, + ); + min.value = min.value.copyWith( + energy: value.min, + ); + max.value = max.value.copyWith( + energy: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.instrumentalness), - values: instrumentalness.value, + values: ( + target: + target.value.instrumentalness?.toDouble() ?? 0, + min: min.value.instrumentalness?.toDouble() ?? 0, + max: max.value.instrumentalness?.toDouble() ?? 0, + ), onChanged: (value) { - instrumentalness.value = value; + target.value = target.value.copyWith( + instrumentalness: value.target, + ); + min.value = min.value.copyWith( + instrumentalness: value.min, + ); + max.value = max.value.copyWith( + instrumentalness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.liveness), - values: liveness.value, + values: ( + target: target.value.liveness?.toDouble() ?? 0, + min: min.value.liveness?.toDouble() ?? 0, + max: max.value.liveness?.toDouble() ?? 0, + ), onChanged: (value) { - liveness.value = value; + target.value = target.value.copyWith( + liveness: value.target, + ); + min.value = min.value.copyWith( + liveness: value.min, + ); + max.value = max.value.copyWith( + liveness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.loudness), - values: loudness.value, + values: ( + target: target.value.loudness?.toDouble() ?? 0, + min: min.value.loudness?.toDouble() ?? 0, + max: max.value.loudness?.toDouble() ?? 0, + ), onChanged: (value) { - loudness.value = value; + target.value = target.value.copyWith( + loudness: value.target, + ); + min.value = min.value.copyWith( + loudness: value.min, + ); + max.value = max.value.copyWith( + loudness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.speechiness), - values: speechiness.value, + values: ( + target: target.value.speechiness?.toDouble() ?? 0, + min: min.value.speechiness?.toDouble() ?? 0, + max: max.value.speechiness?.toDouble() ?? 0, + ), onChanged: (value) { - speechiness.value = value; + target.value = target.value.copyWith( + speechiness: value.target, + ); + min.value = min.value.copyWith( + speechiness: value.min, + ); + max.value = max.value.copyWith( + speechiness: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.valence), - values: valence.value, + values: ( + target: target.value.valence?.toDouble() ?? 0, + min: min.value.valence?.toDouble() ?? 0, + max: max.value.valence?.toDouble() ?? 0, + ), onChanged: (value) { - valence.value = value; + target.value = target.value.copyWith( + valence: value.target, + ); + min.value = min.value.copyWith( + valence: value.min, + ); + max.value = max.value.copyWith( + valence: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.popularity), - values: popularity.value, base: 100, + values: ( + target: target.value.popularity?.toDouble() ?? 0, + min: min.value.popularity?.toDouble() ?? 0, + max: max.value.popularity?.toDouble() ?? 0, + ), onChanged: (value) { - popularity.value = value; + target.value = target.value.copyWith( + popularity: value.target, + ); + min.value = min.value.copyWith( + popularity: value.min, + ); + max.value = max.value.copyWith( + popularity: value.max, + ); }, ), RecommendationAttributeDials( title: Text(context.l10n.key), - values: key.value, base: 11, + values: ( + target: target.value.key?.toDouble() ?? 0, + min: min.value.key?.toDouble() ?? 0, + max: max.value.key?.toDouble() ?? 0, + ), onChanged: (value) { - key.value = value; + target.value = target.value.copyWith( + key: value.target, + ); + min.value = min.value.copyWith( + key: value.min, + ); + max.value = max.value.copyWith( + key: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.duration), values: ( - max: durationMs.value.max / 1000, - target: durationMs.value.target / 1000, - min: durationMs.value.min / 1000, + max: (max.value.durationMs ?? 0) / 1000, + target: (target.value.durationMs ?? 0) / 1000, + min: (min.value.durationMs ?? 0) / 1000, ), onChanged: (value) { - durationMs.value = ( - max: value.max * 1000, - target: value.target * 1000, - min: value.min * 1000, + target.value = target.value.copyWith( + durationMs: (value.target * 1000).toInt(), + ); + min.value = min.value.copyWith( + durationMs: (value.min * 1000).toInt(), + ); + max.value = max.value.copyWith( + durationMs: (value.max * 1000).toInt(), ); }, presets: { @@ -451,23 +563,59 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ), RecommendationAttributeFields( title: Text(context.l10n.tempo), - values: tempo.value, + values: ( + max: max.value.tempo?.toDouble() ?? 0, + target: target.value.tempo?.toDouble() ?? 0, + min: min.value.tempo?.toDouble() ?? 0, + ), onChanged: (value) { - tempo.value = value; + target.value = target.value.copyWith( + tempo: value.target, + ); + min.value = min.value.copyWith( + tempo: value.min, + ); + max.value = max.value.copyWith( + tempo: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.mode), - values: mode.value, + values: ( + max: max.value.mode?.toDouble() ?? 0, + target: target.value.mode?.toDouble() ?? 0, + min: min.value.mode?.toDouble() ?? 0, + ), onChanged: (value) { - mode.value = value; + target.value = target.value.copyWith( + mode: value.target, + ); + min.value = min.value.copyWith( + mode: value.min, + ); + max.value = max.value.copyWith( + mode: value.max, + ); }, ), RecommendationAttributeFields( title: Text(context.l10n.time_signature), - values: timeSignature.value, + values: ( + max: max.value.timeSignature?.toDouble() ?? 0, + target: target.value.timeSignature?.toDouble() ?? 0, + min: min.value.timeSignature?.toDouble() ?? 0, + ), onChanged: (value) { - timeSignature.value = value; + target.value = target.value.copyWith( + timeSignature: value.target, + ); + min.value = min.value.copyWith( + timeSignature: value.min, + ); + max.value = max.value.copyWith( + timeSignature: value.max, + ); }, ), const SizedBox(height: 20), @@ -479,35 +627,18 @@ class PlaylistGeneratorPage extends HookConsumerWidget { genres.value.isEmpty ? null : () { - final PlaylistGenerateResultRouteState - routeState = ( - seeds: ( - artists: artists.value - .map((a) => a.id!) - .toList(), - tracks: tracks.value - .map((t) => t.id!) - .toList(), - genres: genres.value - ), - market: market.value, + final routeState = + GeneratePlaylistProviderInput( + seedArtists: artists.value + .map((a) => a.id!) + .toList(), + seedTracks: + tracks.value.map((t) => t.id!).toList(), + seedGenres: genres.value, limit: limit.value, - parameters: ( - acousticness: acousticness.value, - danceability: danceability.value, - energy: energy.value, - instrumentalness: instrumentalness.value, - liveness: liveness.value, - loudness: loudness.value, - speechiness: speechiness.value, - valence: valence.value, - popularity: popularity.value, - key: key.value, - duration_ms: durationMs.value, - tempo: tempo.value, - mode: mode.value, - time_signature: timeSignature.value, - ) + max: max.value, + min: min.value, + target: target.value, ); GoRouter.of(context).push( "/library/generate/result", diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index f751b65b..deb86a97 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -1,4 +1,3 @@ -import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; @@ -10,249 +9,224 @@ import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/queries/playlist.dart'; -import 'package:spotube/services/queries/queries.dart'; - -typedef PlaylistGenerateResultRouteState = ({ - ({List tracks, List artists, List genres})? seeds, - RecommendationParameters? parameters, - int limit, - Market? market, -}); +import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistGenerateResultPage extends HookConsumerWidget { - final PlaylistGenerateResultRouteState state; + final GeneratePlaylistProviderInput state; const PlaylistGenerateResultPage({ - Key? key, + super.key, required this.state, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { final router = GoRouter.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final (:seeds, :parameters, :limit, :market) = state; - final queryClient = useQueryClient(); - final generatedPlaylist = useQueries.playlist.generate( - ref, - seeds: seeds, - parameters: parameters, - limit: limit, - market: market, - ); + final generatedPlaylist = ref.watch(generatePlaylistProvider(state)); final selectedTracks = useState>( - generatedPlaylist.data?.map((e) => e.id!).toList() ?? [], + generatedPlaylist.asData?.value.map((e) => e.id!).toList() ?? [], ); useEffect(() { - if (generatedPlaylist.data != null) { + if (generatedPlaylist.value != null) { selectedTracks.value = - generatedPlaylist.data!.map((e) => e.id!).toList(); + generatedPlaylist.value!.map((e) => e.id!).toList(); } return null; - }, [generatedPlaylist.data]); + }, [generatedPlaylist.value]); - final isAllTrackSelected = - selectedTracks.value.length == (generatedPlaylist.data?.length ?? 0); + final isAllTrackSelected = selectedTracks.value.length == + (generatedPlaylist.asData?.value.length ?? 0); - return WillPopScope( - onWillPop: () async { - queryClient.cache.removeQuery(generatedPlaylist); - return true; - }, - child: Scaffold( - appBar: const PageWindowTitleBar(leading: BackButton()), - body: generatedPlaylist.isLoading - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - Text(context.l10n.generating_playlist), - ], - ), - ) - : Padding( - padding: const EdgeInsets.all(8.0), - child: ListView( - children: [ - GridView( - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - mainAxisExtent: 32, + return Scaffold( + appBar: const PageWindowTitleBar(leading: BackButton()), + body: generatedPlaylist.isLoading + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + Text(context.l10n.generating_playlist), + ], + ), + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: ListView( + children: [ + GridView( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + mainAxisExtent: 32, + ), + shrinkWrap: true, + children: [ + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.play), + label: Text(context.l10n.play), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.load( + generatedPlaylist.value!.where( + (e) => selectedTracks.value.contains(e.id!), + ), + autoPlay: true, + ); + }, ), - shrinkWrap: true, + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.queueAdd), + label: Text(context.l10n.add_to_queue), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.addTracks( + generatedPlaylist.value!.where( + (e) => selectedTracks.value.contains(e.id!), + ), + ); + if (context.mounted) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.add_count_to_queue( + selectedTracks.value.length, + ), + ), + ), + ); + } + }, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.addFilled), + label: Text(context.l10n.create_a_playlist), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + final playlist = await showDialog( + context: context, + builder: (context) => PlaylistCreateDialog( + trackIds: selectedTracks.value, + ), + ); + + if (playlist != null) { + router.go( + '/playlist/${playlist.id}', + extra: playlist, + ); + } + }, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.playlistAdd), + label: Text(context.l10n.add_to_playlist), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + final hasAdded = await showDialog( + context: context, + builder: (context) => PlaylistAddTrackDialog( + openFromPlaylist: null, + tracks: selectedTracks.value + .map( + (e) => generatedPlaylist.value! + .firstWhere( + (element) => element.id == e, + ), + ) + .toList(), + ), + ); + + if (context.mounted && hasAdded == true) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + context.l10n.add_count_to_playlist( + selectedTracks.value.length, + ), + ), + ), + ); + } + }, + ) + ], + ), + const SizedBox(height: 16), + if (generatedPlaylist.value != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.play), - label: Text(context.l10n.play), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - await playlistNotifier.load( - generatedPlaylist.data!.where( - (e) => - selectedTracks.value.contains(e.id!), - ), - autoPlay: true, - ); - }, + Text( + context.l10n.selected_count_tracks( + selectedTracks.value.length, + ), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.queueAdd), - label: Text(context.l10n.add_to_queue), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - await playlistNotifier.addTracks( - generatedPlaylist.data!.where( - (e) => - selectedTracks.value.contains(e.id!), - ), - ); - if (context.mounted) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_queue( - selectedTracks.value.length, - ), - ), - ), - ); - } - }, + ElevatedButton.icon( + onPressed: () { + if (isAllTrackSelected) { + selectedTracks.value = []; + } else { + selectedTracks.value = generatedPlaylist.value + ?.map((e) => e.id!) + .toList() ?? + []; + } + }, + icon: const Icon(SpotubeIcons.selectionCheck), + label: Text( + isAllTrackSelected + ? context.l10n.deselect_all + : context.l10n.select_all, + ), ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_a_playlist), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - final playlist = await showDialog( - context: context, - builder: (context) => PlaylistCreateDialog( - trackIds: selectedTracks.value, - ), - ); - - if (playlist != null) { - router.go( - '/playlist/${playlist.id}', - extra: playlist, - ); - } - }, - ), - FilledButton.tonalIcon( - icon: const Icon(SpotubeIcons.playlistAdd), - label: Text(context.l10n.add_to_playlist), - onPressed: selectedTracks.value.isEmpty - ? null - : () async { - final hasAdded = await showDialog( - context: context, - builder: (context) => - PlaylistAddTrackDialog( - openFromPlaylist: null, - tracks: selectedTracks.value - .map( - (e) => generatedPlaylist.data! - .firstWhere( - (element) => element.id == e, - ), - ) - .toList(), - ), - ); - - if (context.mounted && hasAdded == true) { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Text( - context.l10n.add_count_to_playlist( - selectedTracks.value.length, - ), - ), - ), - ); - } - }, - ) ], ), - const SizedBox(height: 16), - if (generatedPlaylist.data != null) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + const SizedBox(height: 8), + Card( + margin: const EdgeInsets.all(0), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Text( - context.l10n.selected_count_tracks( - selectedTracks.value.length, - ), - ), - ElevatedButton.icon( - onPressed: () { - if (isAllTrackSelected) { - selectedTracks.value = []; - } else { - selectedTracks.value = generatedPlaylist.data - ?.map((e) => e.id!) - .toList() ?? - []; - } - }, - icon: const Icon(SpotubeIcons.selectionCheck), - label: Text( - isAllTrackSelected - ? context.l10n.deselect_all - : context.l10n.select_all, - ), - ), + for (final track in generatedPlaylist.value ?? []) + CheckboxListTile( + value: selectedTracks.value.contains(track.id), + onChanged: (value) { + if (value == true) { + selectedTracks.value.add(track.id!); + } else { + selectedTracks.value.remove(track.id); + } + selectedTracks.value = + selectedTracks.value.toList(); + }, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + title: SimpleTrackTile(track: track), + ) ], ), - const SizedBox(height: 8), - Card( - margin: const EdgeInsets.all(0), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final track in generatedPlaylist.data ?? []) - CheckboxListTile( - value: selectedTracks.value.contains(track.id), - onChanged: (value) { - if (value == true) { - selectedTracks.value.add(track.id!); - } else { - selectedTracks.value.remove(track.id); - } - selectedTracks.value = - selectedTracks.value.toList(); - }, - controlAffinity: - ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - dense: true, - title: SimpleTrackTile(track: track), - ) - ], - ), - ), ), - ], - ), + ), + ], ), - ), + ), ); } } diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index ac4b61e7..9c777660 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -22,7 +22,7 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class LyricsPage extends HookConsumerWidget { final bool isModal; - const LyricsPage({Key? key, this.isModal = false}) : super(key: key); + const LyricsPage({super.key, this.isModal = false}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 2cf73728..a617909c 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -21,7 +21,7 @@ import 'package:spotube/utils/platform.dart'; class MiniLyricsPage extends HookConsumerWidget { final Size prevSize; - const MiniLyricsPage({Key? key, required this.prevSize}) : super(key: key); + const MiniLyricsPage({super.key, required this.prevSize}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index bee5114d..96ad8d41 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -12,8 +12,8 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlainLyrics extends HookConsumerWidget { @@ -24,14 +24,13 @@ class PlainLyrics extends HookConsumerWidget { required this.palette, this.isModal, this.defaultTextZoom = 100, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final lyricsQuery = - useQueries.lyrics.spotifySynced(ref, playlist.activeTrack); + final lyricsQuery = ref.watch(syncedLyricsProvider(playlist.activeTrack)); final mediaQuery = MediaQuery.of(context); final textTheme = Theme.of(context).textTheme; @@ -96,9 +95,9 @@ class PlainLyrics extends HookConsumerWidget { } final lyrics = - lyricsQuery.data?.lyrics.mapIndexed((i, e) { - final next = - lyricsQuery.data?.lyrics.elementAtOrNull(i + 1); + lyricsQuery.asData?.value.lyrics.mapIndexed((i, e) { + final next = lyricsQuery.asData?.value.lyrics + .elementAtOrNull(i + 1); if (next != null && e.time - next.time > const Duration(milliseconds: 700)) { diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index ddef1c65..872ad514 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -13,14 +13,12 @@ import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/components/lyrics/use_synced_lyrics.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:stroke_text/stroke_text.dart'; -final _delay = StateProvider((ref) => 0); - class SyncedLyrics extends HookConsumerWidget { final PaletteColor palette; final bool? isModal; @@ -30,8 +28,8 @@ class SyncedLyrics extends HookConsumerWidget { required this.palette, this.isModal, this.defaultTextZoom = 100, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -40,28 +38,18 @@ class SyncedLyrics extends HookConsumerWidget { final mediaQuery = MediaQuery.of(context); final controller = useAutoScrollController(); - final delay = ref.watch(_delay); + final delay = ref.watch(syncedLyricsDelayProvider); final timedLyricsQuery = - useQueries.lyrics.spotifySynced(ref, playlist.activeTrack); + ref.watch(syncedLyricsProvider(playlist.activeTrack)); - final lyricValue = timedLyricsQuery.data; + final lyricValue = timedLyricsQuery.asData?.value; - final isUnSyncLyric = useMemoized( - () => lyricValue?.lyrics.every((l) => l.time == Duration.zero), - [lyricValue], + final lyricsState = ref.watch( + syncedLyricsMapProvider(playlist.activeTrack), ); - - final lyricsMap = useMemoized( - () => - lyricValue?.lyrics - .map((lyric) => {lyric.time.inSeconds: lyric.text}) - .reduce((accumulator, lyricSlice) => - {...accumulator, ...lyricSlice}) ?? - {}, - [lyricValue], - ); - final currentTime = useSyncedLyrics(ref, lyricsMap, delay); + final currentTime = + useSyncedLyrics(ref, lyricsState.asData?.value.lyricsMap ?? {}, delay); final textZoomLevel = useState(defaultTextZoom); final textTheme = Theme.of(context).textTheme; @@ -70,7 +58,7 @@ class SyncedLyrics extends HookConsumerWidget { ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), (previous, next) { controller.scrollToIndex(0); - ref.read(_delay.notifier).state = 0; + ref.read(syncedLyricsDelayProvider.notifier).state = 0; }, ); @@ -105,7 +93,7 @@ class SyncedLyrics extends HookConsumerWidget { ), if (lyricValue != null && lyricValue.lyrics.isNotEmpty && - isUnSyncLyric == false) + lyricsState.asData?.value.static != true) Expanded( child: ListView.builder( controller: controller, @@ -202,7 +190,7 @@ class SyncedLyrics extends HookConsumerWidget { ), const Gap(26), const Icon(SpotubeIcons.noLyrics, size: 60), - ] else if (isUnSyncLyric == true) + ] else if (lyricsState.asData?.value.static == true) Expanded( child: Center( child: RichText( @@ -235,7 +223,8 @@ class SyncedLyrics extends HookConsumerWidget { final actions = [ ZoomControls( value: delay, - onChanged: (value) => ref.read(_delay.notifier).state = value, + onChanged: (value) => + ref.read(syncedLyricsDelayProvider.notifier).state = value, interval: 1, unit: "s", increaseIcon: const Icon(SpotubeIcons.add), diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 8b9bce4c..d9a309ed 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -8,7 +8,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/platform.dart'; class WebViewLogin extends HookConsumerWidget { - const WebViewLogin({Key? key}) : super(key: key); + const WebViewLogin({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 1fb2e1dc..eeea8cb1 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -3,19 +3,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class LikedPlaylistPage extends HookConsumerWidget { final PlaylistSimple playlist; const LikedPlaylistPage({ - Key? key, + super.key, required this.playlist, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final likedTracks = useQueries.playlist.likedTracksQuery(ref); - final tracks = likedTracks.data ?? []; + final likedTracks = ref.watch(likedTracksProvider); + final tracks = likedTracks.value ?? []; return InheritedTrackView( collectionId: playlist.id!, @@ -28,7 +28,7 @@ class LikedPlaylistPage extends HookConsumerWidget { return tracks.toList(); }, onRefresh: () async { - await likedTracks.refresh(); + ref.invalidate(likedTracksProvider); }, ), title: playlist.name!, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 89a279ab..7962c66a 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,5 +1,4 @@ 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/dialogs/prompt_dialog.dart'; @@ -7,46 +6,25 @@ import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_ import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/infinite_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/services/mutations/mutations.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistPage extends HookConsumerWidget { final PlaylistSimple playlist; const PlaylistPage({ - Key? key, + super.key, required this.playlist, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - final tracksQuery = useQueries.playlist.tracksOfQuery(ref, playlist.id!); - - final tracks = useMemoized( - () { - return tracksQuery.pages.expand((page) => page).toList(); - }, - [tracksQuery.pages], - ); - - final me = useQueries.user.me(ref); - - final isLikedQuery = useQueries.playlist.doesUserFollow( - ref, - playlist.id!, - me.data?.id ?? '', - ); - - final togglePlaylistLike = useMutations.playlist.toggleFavorite( - ref, - playlist.id!, - refreshQueries: [ - isLikedQuery.key, - ], - ); + final tracks = ref.watch(playlistTracksProvider(playlist.id!)); + final tracksNotifier = + ref.watch(playlistTracksProvider(playlist.id!).notifier); + final isFavoritePlaylist = + ref.watch(isFavoritePlaylistProvider(playlist.id!)); + final favoritePlaylistsNotifier = + ref.watch(favoritePlaylistsProvider.notifier); final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); @@ -56,42 +34,42 @@ class PlaylistPage extends HookConsumerWidget { playlist.images, placeholder: ImagePlaceholder.collection, ), - pagination: PaginationProps.fromQuery( - tracksQuery, - onFetchAll: () { - return tracksQuery.fetchAllTracks( - getAllTracks: () async { - final res = await spotify.playlists - .getTracksByPlaylistId(playlist.id!) - .all(); - return res.toList(); - }, - ); + pagination: PaginationProps( + hasNextPage: tracks.asData?.value.hasMore ?? false, + isLoading: tracks.isLoadingNextPage, + onFetchMore: tracksNotifier.fetchMore, + onRefresh: () async { + ref.invalidate(playlistTracksProvider(playlist.id!)); + }, + onFetchAll: () async { + return await tracksNotifier.fetchAll(); }, ), title: playlist.name!, description: playlist.description, - tracks: tracks, + tracks: tracks.asData?.value.items ?? [], routePath: '/playlist/${playlist.id}', - isLiked: isLikedQuery.data ?? false, + isLiked: isFavoritePlaylist.value ?? false, shareUrl: playlist.externalUrls?.spotify ?? "", - onHeart: () async { - if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) { - return false; - } - final confirmed = isUserPlaylist - ? await showPromptDialog( - context: context, - title: context.l10n.delete_playlist, - message: context.l10n.delete_playlist_confirmation, - ) - : true; - if (confirmed) { - await togglePlaylistLike.mutate(isLikedQuery.data!); - return isUserPlaylist; - } - return null; - }, + onHeart: isFavoritePlaylist.value == null + ? null + : () async { + final confirmed = isUserPlaylist + ? await showPromptDialog( + context: context, + title: context.l10n.delete_playlist, + message: context.l10n.delete_playlist_confirmation, + ) + : true; + if (!confirmed) return null; + + if (isFavoritePlaylist.value!) { + await favoritePlaylistsNotifier.removeFavorite(playlist); + } else { + await favoritePlaylistsNotifier.addFavorite(playlist); + } + return isUserPlaylist; + }, child: const TrackView(), ); } diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index aaf3e30a..b562adab 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; @@ -18,6 +17,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/hooks/configurators/use_update_checker.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; const rootPaths = { @@ -31,8 +31,8 @@ class RootApp extends HookConsumerWidget { final Widget child; const RootApp({ required this.child, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { @@ -53,8 +53,9 @@ class RootApp extends HookConsumerWidget { } }); - final subscription = - QueryClient.connectivity.onConnectivityChanged.listen((status) { + final subscription = ConnectionCheckerService + .instance.onConnectivityChanged + .listen((status) { if (status) { scaffoldMessenger.showSnackBar( SnackBar( diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index f4a78d4f..e666c9aa 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -16,59 +17,33 @@ import 'package:spotube/pages/search/sections/artists.dart'; import 'package:spotube/pages/search/sections/playlists.dart'; import 'package:spotube/pages/search/sections/tracks.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/platform.dart'; import 'package:collection/collection.dart'; -final searchTermStateProvider = StateProvider((ref) => ""); - class SearchPage extends HookConsumerWidget { - const SearchPage({Key? key}) : super(key: key); + const SearchPage({super.key}); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); + final searchTerm = ref.watch(searchTermStateProvider); + final controller = useTextEditingController(text: searchTerm); + ref.watch(AuthenticationNotifier.provider); final authenticationNotifier = ref.watch(AuthenticationNotifier.provider.notifier); final mediaQuery = MediaQuery.of(context); - final searchTerm = ref.watch(searchTermStateProvider); - - final searchTrack = - useQueries.search.query(ref, searchTerm, SearchType.track); - final searchAlbum = - useQueries.search.query(ref, searchTerm, SearchType.album); - final searchPlaylist = - useQueries.search.query(ref, searchTerm, SearchType.playlist); - final searchArtist = - useQueries.search.query(ref, searchTerm, SearchType.artist); - - Future onSearch() async { - await Future.wait([ - searchTrack.reset(), - searchAlbum.reset(), - searchPlaylist.reset(), - searchArtist.reset(), - ]).then((_) { - return Future.wait([ - searchTrack.refreshAll(), - searchAlbum.refreshAll(), - searchPlaylist.refreshAll(), - searchArtist.refreshAll(), - ]); - }); - } + final searchTrack = ref.watch(searchProvider(SearchType.track)); + final searchAlbum = ref.watch(searchProvider(SearchType.album)); + final searchPlaylist = ref.watch(searchProvider(SearchType.playlist)); + final searchArtist = ref.watch(searchProvider(SearchType.artist)); final queries = [searchTrack, searchAlbum, searchPlaylist, searchArtist]; - final isFetching = queries.every( - (s) => - (!s.hasPageData && !s.hasPageError) || - s.isRefreshingPage || - !s.hasPageData, - ) && - searchTerm.isNotEmpty; + + final isFetching = queries.every((s) => s.isLoading); final resultWidget = HookBuilder( builder: (context) { @@ -78,18 +53,18 @@ class SearchPage extends HookConsumerWidget { controller: controller, child: SingleChildScrollView( controller: controller, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 8), child: SafeArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SearchTracksSection(query: searchTrack), - SearchPlaylistsSection(query: searchPlaylist), - const SizedBox(height: 20), - SearchArtistsSection(query: searchArtist), - const SizedBox(height: 20), - SearchAlbumsSection(query: searchAlbum), + SearchTracksSection(), + SearchPlaylistsSection(), + Gap(20), + SearchArtistsSection(), + Gap(20), + SearchAlbumsSection(), ], ), ), @@ -114,21 +89,22 @@ class SearchPage extends HookConsumerWidget { ), color: theme.scaffoldBackgroundColor, child: TextField( - autofocus: queries - .none((s) => s.hasPageData && !s.hasPageError) && - !kIsMobile, + controller: controller, + autofocus: + queries.none((s) => s.value != null && !s.hasError) && + !kIsMobile, decoration: InputDecoration( prefixIcon: const Icon(SpotubeIcons.search), hintText: "${context.l10n.search}...", ), onSubmitted: (value) async { - ref.read(searchTermStateProvider.notifier).state = - value; - // Fl-Query is too fast, so we need to delay the search - // to prevent spamming the API :) - Timer(const Duration(milliseconds: 50), () { - onSearch(); - }); + Timer( + const Duration(milliseconds: 50), + () { + ref.read(searchTermStateProvider.notifier).state = + value; + }, + ); }, ), ), diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index 8aa33feb..6d0f1508 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -1,5 +1,3 @@ -import 'package:fl_query/fl_query.dart'; - import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -7,33 +5,33 @@ 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/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class SearchAlbumsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchAlbumsSection({ - required this.query, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { + final query = ref.watch(searchProvider(SearchType.album)); + final notifier = ref.watch(searchProvider(SearchType.album).notifier); final albums = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .map((e) => TypeConversionUtils.simpleAlbum_X_Album(e)) - .toList(), - [query.pages], + () => + query.asData?.value.items + .cast() + .map(TypeConversionUtils.simpleAlbum_X_Album) + .toList() ?? + [], + [query.value], ); return HorizontalPlaybuttonCardView( isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + hasNextPage: query.asData?.value.hasMore == true, items: albums, - onFetchMore: query.fetchNext, + onFetchMore: notifier.fetchMore, title: Text(context.l10n.albums), ); } diff --git a/lib/pages/search/sections/artists.dart b/lib/pages/search/sections/artists.dart index fe4459d6..bb8063dc 100644 --- a/lib/pages/search/sections/artists.dart +++ b/lib/pages/search/sections/artists.dart @@ -1,37 +1,28 @@ -import 'package:fl_query/fl_query.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/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchArtistsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; - const SearchArtistsSection({ - Key? key, - required this.query, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final artists = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .toList(), - [query.pages], - ); + final query = ref.watch(searchProvider(SearchType.artist)); + final notifier = ref.watch(searchProvider(SearchType.artist).notifier); + + final artists = query.asData?.value.items.cast() ?? []; return HorizontalPlaybuttonCardView( isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + hasNextPage: query.asData?.value.hasMore == true, items: artists, - onFetchMore: query.fetchNext, + onFetchMore: notifier.fetchMore, title: Text(context.l10n.artists), ); } diff --git a/lib/pages/search/sections/playlists.dart b/lib/pages/search/sections/playlists.dart index 47614a70..13ff483d 100644 --- a/lib/pages/search/sections/playlists.dart +++ b/lib/pages/search/sections/playlists.dart @@ -1,35 +1,28 @@ -import 'package:fl_query/fl_query.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/extensions/context.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchPlaylistsSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchPlaylistsSection({ - required this.query, - Key? key, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final playlists = useMemoized( - () => query.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType() - .toList(), - [query.pages], - ); + final playlistsQuery = ref.watch(searchProvider(SearchType.playlist)); + final playlistsQueryNotifier = + ref.watch(searchProvider(SearchType.playlist).notifier); + final playlists = + playlistsQuery.asData?.value.items.cast() ?? []; return HorizontalPlaybuttonCardView( - isLoadingNextPage: query.isLoadingNextPage, - hasNextPage: query.hasNextPage, + isLoadingNextPage: playlistsQuery.isLoadingNextPage, + hasNextPage: playlistsQuery.asData?.value.hasMore == true, items: playlists, - onFetchMore: query.fetchNext, + onFetchMore: playlistsQueryNotifier.fetchMore, title: Text(context.l10n.playlists), ); } diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index e77cd8f2..0fdb50af 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -1,32 +1,26 @@ import 'package:collection/collection.dart'; -import 'package:fl_query/fl_query.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/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class SearchTracksSection extends HookConsumerWidget { - final InfiniteQuery>, dynamic, int> query; const SearchTracksSection({ - Key? key, - required this.query, - }) : super(key: key); + super.key, + }); @override Widget build(BuildContext context, ref) { - final searchTrack = query; - final tracks = useMemoized( - () => searchTrack.pages - .expand( - (page) => page.map((p) => p.items!).expand((element) => element), - ) - .whereType(), - [searchTrack.pages], - ); + final searchTrack = ref.watch(searchProvider(SearchType.track)); + + final searchTrackNotifier = + ref.watch(searchProvider(SearchType.track).notifier); + + final tracks = searchTrack.asData?.value.items.cast() ?? []; final playlistNotifier = ref.watch(ProxyPlaylistNotifier.provider.notifier); final playlist = ref.watch(ProxyPlaylistNotifier.provider); final theme = Theme.of(context); @@ -43,14 +37,10 @@ class SearchTracksSection extends HookConsumerWidget { style: theme.textTheme.titleLarge!, ), ), - if (!searchTrack.hasPageData && - !searchTrack.hasPageError && - !searchTrack.isLoadingNextPage) + if (searchTrack.isLoading) const CircularProgressIndicator() - else if (searchTrack.hasPageError) - Text( - searchTrack.errors.lastOrNull?.toString() ?? "", - ) + else if (searchTrack.hasError) + Text(searchTrack.error.toString()) else ...tracks.mapIndexed((i, track) { return TrackTile( @@ -81,12 +71,12 @@ class SearchTracksSection extends HookConsumerWidget { }, ); }), - if (searchTrack.hasNextPage && tracks.isNotEmpty) + if (searchTrack.asData?.value.hasMore == true && tracks.isNotEmpty) Center( child: TextButton( onPressed: searchTrack.isLoadingNextPage ? null - : () => searchTrack.fetchNext(), + : () => searchTrackNotifier.fetchMore, child: searchTrack.isLoadingNextPage ? const CircularProgressIndicator() : Text(context.l10n.load_more), diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 00263680..21b8117b 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -16,7 +16,7 @@ final _licenseProvider = FutureProvider((ref) async { }); class AboutSpotube extends HookConsumerWidget { - const AboutSpotube({Key? key}) : super(key: key); + const AboutSpotube({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index b4ce5044..45ce76d9 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; class BlackListPage extends HookConsumerWidget { - const BlackListPage({Key? key}) : super(key: key); + const BlackListPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index cfb28d18..b07ebbb1 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; class LogsPage extends HookWidget { - const LogsPage({Key? key}) : super(key: key); + const LogsPage({super.key}); List<({DateTime? date, String body})> parseLogs(String raw) { return raw diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index 9fe59662..a8d72cc0 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -11,7 +11,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:url_launcher/url_launcher_string.dart'; class SettingsAboutSection extends HookConsumerWidget { - const SettingsAboutSection({Key? key}) : super(key: key); + const SettingsAboutSection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 83740866..bded71b3 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -10,7 +10,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; class SettingsAccountSection extends HookConsumerWidget { - const SettingsAccountSection({Key? key}) : super(key: key); + const SettingsAccountSection({super.key}); @override Widget build(context, ref) { diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart index 3d941212..25bd4005 100644 --- a/lib/pages/settings/sections/appearance.dart +++ b/lib/pages/settings/sections/appearance.dart @@ -13,9 +13,9 @@ import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class SettingsAppearanceSection extends HookConsumerWidget { final bool isGettingStarted; const SettingsAppearanceSection({ - Key? key, + super.key, this.isGettingStarted = false, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index ae721fc4..2c0a1466 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -9,7 +9,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class SettingsDesktopSection extends HookConsumerWidget { - const SettingsDesktopSection({Key? key}) : super(key: key); + const SettingsDesktopSection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/developers.dart b/lib/pages/settings/sections/developers.dart index 4b5f58a6..a22cf9f1 100644 --- a/lib/pages/settings/sections/developers.dart +++ b/lib/pages/settings/sections/developers.dart @@ -6,7 +6,7 @@ import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; class SettingsDevelopersSection extends HookWidget { - const SettingsDevelopersSection({Key? key}) : super(key: key); + const SettingsDevelopersSection({super.key}); @override Widget build(BuildContext context) { diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index b1e360d0..1f25028e 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -10,7 +10,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class SettingsDownloadsSection extends HookConsumerWidget { - const SettingsDownloadsSection({Key? key}) : super(key: key); + const SettingsDownloadsSection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index bd2e33b9..b3f0d897 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -14,7 +14,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/sourced_track/enums.dart'; class SettingsPlaybackSection extends HookConsumerWidget { - const SettingsPlaybackSection({Key? key}) : super(key: key); + const SettingsPlaybackSection({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index f773b809..d2a75057 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -16,7 +16,7 @@ import 'package:spotube/pages/settings/sections/playback.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class SettingsPage extends HookConsumerWidget { - const SettingsPage({Key? key}) : super(key: key); + const SettingsPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index 14052c10..ca5dbf95 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -13,17 +13,17 @@ import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/extensions/constrains.dart'; class TrackPage extends HookConsumerWidget { final String trackId; const TrackPage({ - Key? key, + super.key, required this.trackId, - }) : super(key: key); + }); @override Widget build(BuildContext context, ref) { @@ -35,9 +35,9 @@ class TrackPage extends HookConsumerWidget { final isActive = playlist.activeTrack?.id == trackId; - final trackQuery = useQueries.tracks.track(ref, trackId); + final trackQuery = ref.watch(trackProvider(trackId)); - final track = trackQuery.data ?? FakeData.track; + final track = trackQuery.asData?.value ?? FakeData.track; void onPlay() async { if (isActive) { diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index cd77e7bb..f1cf58ec 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:convert'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; @@ -52,8 +51,7 @@ class AuthenticationCredentials { ), ); } catch (e) { - if (rootNavigatorKey?.currentContext != null && - await QueryClient.connectivity.isConnected) { + if (rootNavigatorKey?.currentContext != null) { showPromptDialog( context: rootNavigatorKey!.currentContext!, title: rootNavigatorKey!.currentContext!.l10n diff --git a/lib/provider/blacklist_provider.dart b/lib/provider/blacklist_provider.dart index 363d4b4c..1d4edebf 100644 --- a/lib/provider/blacklist_provider.dart +++ b/lib/provider/blacklist_provider.dart @@ -62,7 +62,7 @@ class BlackListNotifier final containsTrackArtists = track.artists?.any( (artist) => state.contains( - BlacklistedElement.artist(artist.id!, artist.name!), + BlacklistedElement.artist(artist.id!, artist.name ?? "Spotify"), ), ) ?? false; diff --git a/lib/provider/custom_spotify_endpoint_provider.dart b/lib/provider/custom_spotify_endpoint_provider.dart index 4857a358..7a4c5533 100644 --- a/lib/provider/custom_spotify_endpoint_provider.dart +++ b/lib/provider/custom_spotify_endpoint_provider.dart @@ -1,8 +1,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/custom_spotify_endpoints/spotify_endpoints.dart'; final customSpotifyEndpointProvider = Provider((ref) { + ref.watch(spotifyProvider); final auth = ref.watch(AuthenticationNotifier.provider); return CustomSpotifyEndpoints(auth?.accessToken ?? ""); }); diff --git a/lib/provider/spotify/album/favorite.dart b/lib/provider/spotify/album/favorite.dart new file mode 100644 index 00000000..cf444d49 --- /dev/null +++ b/lib/provider/spotify/album/favorite.dart @@ -0,0 +1,86 @@ +part of '../spotify.dart'; + +class FavoriteAlbumState extends PaginatedState { + FavoriteAlbumState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FavoriteAlbumState copyWith({items, offset, limit, hasMore}) { + return FavoriteAlbumState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FavoriteAlbumNotifier + extends PaginatedAsyncNotifier { + @override + Future> fetch(int offset, int limit) { + return spotify.me + .savedAlbums() + .getPage(limit, offset) + .then((value) => value.items?.toList() ?? []); + } + + @override + build() async { + ref.watch(spotifyProvider); + final items = await fetch(0, 20); + return FavoriteAlbumState( + items: items, + offset: 0, + limit: 20, + hasMore: items.length == 20, + ); + } + + Future addFavorites(List ids) async { + if (state.value == null) return; + + state = await AsyncValue.guard(() async { + await spotify.me.saveAlbums(ids); + final albums = await spotify.albums.list(ids); + + return state.value!.copyWith( + items: [ + ...state.value!.items, + ...albums, + ], + ); + }); + + for (final id in ids) { + ref.invalidate(albumsIsSavedProvider(id)); + } + } + + Future removeFavorites(List ids) async { + if (state.value == null) return; + + state = await AsyncValue.guard(() async { + await spotify.me.removeAlbums(ids); + + return state.value!.copyWith( + items: state.value!.items + .where((element) => !ids.contains(element.id)) + .toList(), + ); + }); + + for (final id in ids) { + ref.invalidate(albumsIsSavedProvider(id)); + } + } +} + +final favoriteAlbumsProvider = + AsyncNotifierProvider( + () => FavoriteAlbumNotifier(), +); diff --git a/lib/provider/spotify/album/is_saved.dart b/lib/provider/spotify/album/is_saved.dart new file mode 100644 index 00000000..987ccdf2 --- /dev/null +++ b/lib/provider/spotify/album/is_saved.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final albumsIsSavedProvider = FutureProvider.autoDispose.family( + (ref, albumId) async { + final spotify = ref.watch(spotifyProvider); + return spotify.me.containsSavedAlbums([albumId]).then( + (value) => value[albumId] ?? false, + ); + }, +); diff --git a/lib/provider/spotify/album/releases.dart b/lib/provider/spotify/album/releases.dart new file mode 100644 index 00000000..471df707 --- /dev/null +++ b/lib/provider/spotify/album/releases.dart @@ -0,0 +1,90 @@ +part of '../spotify.dart'; + +class AlbumReleasesState extends PaginatedState { + AlbumReleasesState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + AlbumReleasesState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return AlbumReleasesState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class AlbumReleasesNotifier + extends PaginatedAsyncNotifier { + AlbumReleasesNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final market = ref.read(userPreferencesProvider).recommendationMarket; + + final albums = await spotify.browse + .newReleases(country: market) + .getPage(limit, offset); + + return albums.items + ?.map(TypeConversionUtils.simpleAlbum_X_Album) + .toList() ?? + []; + } + + @override + build() async { + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + ref.watch(allFollowedArtistsProvider); + + final albums = await fetch(0, 20); + + return AlbumReleasesState( + items: albums, + offset: 0, + limit: 20, + hasMore: albums.length == 20, + ); + } +} + +final albumReleasesProvider = + AsyncNotifierProvider( + () => AlbumReleasesNotifier(), +); + +final userArtistAlbumReleasesProvider = Provider>((ref) { + final newReleases = ref.watch(albumReleasesProvider); + final userArtistsQuery = ref.watch(allFollowedArtistsProvider); + + if (newReleases.isLoading || userArtistsQuery.isLoading) { + return const []; + } + + final userArtists = + userArtistsQuery.asData?.value.map((s) => s.id!).toList() ?? const []; + + final allReleases = newReleases.asData?.value.items; + final userArtistReleases = allReleases?.where((album) { + return album.artists?.any((artist) => userArtists.contains(artist.id!)) == + true; + }).toList(); + + if (userArtistReleases?.isEmpty == true) { + return allReleases?.toList() ?? []; + } + return userArtistReleases ?? []; +}); diff --git a/lib/provider/spotify/album/tracks.dart b/lib/provider/spotify/album/tracks.dart new file mode 100644 index 00000000..9556cc52 --- /dev/null +++ b/lib/provider/spotify/album/tracks.dart @@ -0,0 +1,58 @@ +part of '../spotify.dart'; + +class AlbumTracksState extends PaginatedState { + AlbumTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + AlbumTracksState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return AlbumTracksState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier { + AlbumTracksNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final tracks = await spotify.albums.tracks(arg.id!).getPage(limit, offset); + return tracks.items + ?.map((e) => TypeConversionUtils.simpleTrack_X_Track(e, arg)) + .toList() ?? + []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + final tracks = await fetch(arg, 0, 20); + return AlbumTracksState( + items: tracks, + offset: 0, + limit: 20, + hasMore: tracks.length == 20, + ); + } +} + +final albumTracksProvider = AutoDisposeAsyncNotifierProviderFamily< + AlbumTracksNotifier, AlbumTracksState, AlbumSimple>( + () => AlbumTracksNotifier(), +); diff --git a/lib/provider/spotify/artist/albums.dart b/lib/provider/spotify/artist/albums.dart new file mode 100644 index 00000000..16bd8768 --- /dev/null +++ b/lib/provider/spotify/artist/albums.dart @@ -0,0 +1,62 @@ +part of '../spotify.dart'; + +class ArtistAlbumsState extends PaginatedState { + ArtistAlbumsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + ArtistAlbumsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return ArtistAlbumsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class ArtistAlbumsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + Album, ArtistAlbumsState, String> { + ArtistAlbumsNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final market = ref.read(userPreferencesProvider).recommendationMarket; + final albums = await spotify.artists + .albums(arg, country: market) + .getPage(limit, offset); + + return albums.items?.toList() ?? []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + final albums = await fetch(arg, 0, 20); + return ArtistAlbumsState( + items: albums, + offset: 0, + limit: 20, + hasMore: albums.length == 20, + ); + } +} + +final artistAlbumsProvider = AutoDisposeAsyncNotifierProviderFamily< + ArtistAlbumsNotifier, ArtistAlbumsState, String>( + () => ArtistAlbumsNotifier(), +); diff --git a/lib/provider/spotify/artist/artist.dart b/lib/provider/spotify/artist/artist.dart new file mode 100644 index 00000000..c69badd2 --- /dev/null +++ b/lib/provider/spotify/artist/artist.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final artistProvider = + FutureProvider.autoDispose.family((ref, String artistId) { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + + return spotify.artists.get(artistId); +}); diff --git a/lib/provider/spotify/artist/following.dart b/lib/provider/spotify/artist/following.dart new file mode 100644 index 00000000..4e6bcfe8 --- /dev/null +++ b/lib/provider/spotify/artist/following.dart @@ -0,0 +1,104 @@ +part of '../spotify.dart'; + +class FollowedArtistsState extends CursorPaginatedState { + FollowedArtistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FollowedArtistsState copyWith({ + List? items, + String? offset, + int? limit, + bool? hasMore, + }) { + return FollowedArtistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FollowedArtistsNotifier + extends CursorPaginatedAsyncNotifier { + FollowedArtistsNotifier() : super(); + + @override + fetch(offset, limit) async { + final artists = await spotify.me.following(FollowingType.artist).getPage( + limit, + offset ?? '', + ); + + return (artists.items?.toList() ?? [], artists.after); + } + + @override + build() async { + ref.watch(spotifyProvider); + final (artists, nextCursor) = await fetch(null, 50); + return FollowedArtistsState( + items: artists, + offset: nextCursor, + limit: 50, + hasMore: artists.length == 50, + ); + } + + Future saveArtists(List artistIds) async { + if (state.value == null) return; + await spotify.me.follow(FollowingType.artist, artistIds); + + state = await AsyncValue.guard(() async { + final artists = await spotify.artists.list(artistIds); + + return state.value!.copyWith( + items: [ + ...state.value!.items, + ...artists, + ], + ); + }); + + for (final id in artistIds) { + ref.invalidate(artistIsFollowingProvider(id)); + } + } + + Future removeArtists(List artistIds) async { + if (state.value == null) return; + await spotify.me.unfollow(FollowingType.artist, artistIds); + + state = await AsyncValue.guard(() async { + final artists = state.value!.items.where((artist) { + return !artistIds.contains(artist.id); + }).toList(); + + return state.value!.copyWith( + items: artists, + ); + }); + + for (final id in artistIds) { + ref.invalidate(artistIsFollowingProvider(id)); + } + } +} + +final followedArtistsProvider = + AsyncNotifierProvider( + () => FollowedArtistsNotifier(), +); + +final allFollowedArtistsProvider = FutureProvider>( + (ref) async { + final spotify = ref.watch(spotifyProvider); + final artists = await spotify.me.following(FollowingType.artist).all(); + return artists.toList(); + }, +); diff --git a/lib/provider/spotify/artist/is_following.dart b/lib/provider/spotify/artist/is_following.dart new file mode 100644 index 00000000..db1be184 --- /dev/null +++ b/lib/provider/spotify/artist/is_following.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final artistIsFollowingProvider = FutureProvider.family( + (ref, String artistId) async { + final spotify = ref.watch(spotifyProvider); + return spotify.me.checkFollowing(FollowingType.artist, [artistId]).then( + (value) => value[artistId] ?? false, + ); + }, +); diff --git a/lib/provider/spotify/artist/related.dart b/lib/provider/spotify/artist/related.dart new file mode 100644 index 00000000..317feba3 --- /dev/null +++ b/lib/provider/spotify/artist/related.dart @@ -0,0 +1,11 @@ +part of '../spotify.dart'; + +final relatedArtistsProvider = FutureProvider.autoDispose + .family, String>((ref, artistId) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + final artists = await spotify.artists.relatedArtists(artistId); + + return artists.toList(); +}); diff --git a/lib/provider/spotify/artist/top_tracks.dart b/lib/provider/spotify/artist/top_tracks.dart new file mode 100644 index 00000000..fa40d646 --- /dev/null +++ b/lib/provider/spotify/artist/top_tracks.dart @@ -0,0 +1,15 @@ +part of '../spotify.dart'; + +final artistTopTracksProvider = + FutureProvider.autoDispose.family, String>( + (ref, artistId) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + final market = ref + .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + final tracks = await spotify.artists.topTracks(artistId, market); + + return tracks.toList(); + }, +); diff --git a/lib/provider/spotify/artist/wikipedia.dart b/lib/provider/spotify/artist/wikipedia.dart new file mode 100644 index 00000000..b2e2e6dc --- /dev/null +++ b/lib/provider/spotify/artist/wikipedia.dart @@ -0,0 +1,12 @@ +part of '../spotify.dart'; + +final artistWikipediaSummaryProvider = FutureProvider.autoDispose + .family((ref, artist) async { + final query = artist.name!.replaceAll(" ", "_"); + final res = await wikipedia.pageContent.pageSummaryTitleGet(query); + + if (res?.type != "standard") { + return await wikipedia.pageContent.pageSummaryTitleGet("${query}_(singer)"); + } + return res; +}); diff --git a/lib/provider/spotify/category/categories.dart b/lib/provider/spotify/category/categories.dart new file mode 100644 index 00000000..7652215c --- /dev/null +++ b/lib/provider/spotify/category/categories.dart @@ -0,0 +1,20 @@ +part of '../spotify.dart'; + +final categoriesProvider = FutureProvider( + (ref) async { + final spotify = ref.watch(spotifyProvider); + final market = ref + .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); + final categories = await spotify.categories + .list( + country: market, + locale: Intl.canonicalizedLocale( + locale.toString(), + ), + ) + .all(); + + return categories.toList()..shuffle(); + }, +); diff --git a/lib/provider/spotify/category/genres.dart b/lib/provider/spotify/category/genres.dart new file mode 100644 index 00000000..b4b75b7b --- /dev/null +++ b/lib/provider/spotify/category/genres.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +final categoryGenresProvider = FutureProvider>((ref) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + return await customSpotify.listGenreSeeds(); +}); diff --git a/lib/provider/spotify/category/playlists.dart b/lib/provider/spotify/category/playlists.dart new file mode 100644 index 00000000..979b7f31 --- /dev/null +++ b/lib/provider/spotify/category/playlists.dart @@ -0,0 +1,67 @@ +part of '../spotify.dart'; + +class CategoryPlaylistsState extends PaginatedState { + CategoryPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + CategoryPlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return CategoryPlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + PlaylistSimple, CategoryPlaylistsState, String> { + CategoryPlaylistsNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final preferences = ref.read(userPreferencesProvider); + final playlists = await Pages( + spotify, + "v1/browse/categories/$arg/playlists?country=${preferences.recommendationMarket.name}&locale=${preferences.locale}", + (json) => json == null ? null : PlaylistSimple.fromJson(json), + 'playlists', + (json) => PlaylistsFeatured.fromJson(json), + ).getPage(limit, offset); + + return playlists.items?.whereNotNull().toList() ?? []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + ref.watch(userPreferencesProvider.select((s) => s.locale)); + ref.watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + + final playlists = await fetch(arg, 0, 8); + + return CategoryPlaylistsState( + items: playlists, + offset: 0, + limit: 8, + hasMore: playlists.length == 8, + ); + } +} + +final categoryPlaylistsProvider = AutoDisposeAsyncNotifierProviderFamily< + CategoryPlaylistsNotifier, CategoryPlaylistsState, String>( + () => CategoryPlaylistsNotifier(), +); diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart new file mode 100644 index 00000000..d86735db --- /dev/null +++ b/lib/provider/spotify/lyrics/synced.dart @@ -0,0 +1,77 @@ +part of '../spotify.dart'; + +class SyncedLyricsNotifier extends FamilyAsyncNotifier + with Persistence { + SyncedLyricsNotifier() { + load(); + } + + @override + FutureOr build(track) async { + final spotify = ref.watch(spotifyProvider); + if (track == null) { + throw "No track currently"; + } + final token = await spotify.getCredentials(); + final res = await http.get( + Uri.parse( + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token", + ), + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", + "App-platform": "WebPlayer", + "authorization": "Bearer ${token.accessToken}" + }); + + if (res.statusCode != 200) { + throw Exception("Unable to find lyrics"); + } + final linesRaw = Map.castFrom( + jsonDecode(res.body), + )["lyrics"]?["lines"] as List?; + + final lines = linesRaw?.map((line) { + return LyricSlice( + time: Duration(milliseconds: int.parse(line["startTimeMs"])), + text: line["words"] as String, + ); + }).toList() ?? + []; + + return SubtitleSimple( + lyrics: lines, + name: track.name!, + uri: res.request!.url, + rating: 100, + ); + } + + @override + FutureOr fromJson(Map json) => + SubtitleSimple.fromJson(json.castKeyDeep()); + + @override + Map toJson(SubtitleSimple data) => data.toJson(); +} + +final syncedLyricsDelayProvider = StateProvider((ref) => 0); + +final syncedLyricsProvider = + AsyncNotifierProviderFamily( + () => SyncedLyricsNotifier(), +); + +final syncedLyricsMapProvider = + FutureProvider.family((ref, Track? track) async { + final syncedLyrics = await ref.watch(syncedLyricsProvider(track).future); + + final isStaticLyrics = + syncedLyrics.lyrics.every((l) => l.time == Duration.zero); + + final lyricsMap = syncedLyrics.lyrics + .map((lyric) => {lyric.time.inSeconds: lyric.text}) + .reduce((accumulator, lyricSlice) => {...accumulator, ...lyricSlice}); + + return (static: isStaticLyrics, lyricsMap: lyricsMap); +}); diff --git a/lib/provider/spotify/playlist/favorite.dart b/lib/provider/spotify/playlist/favorite.dart new file mode 100644 index 00000000..a0e051aa --- /dev/null +++ b/lib/provider/spotify/playlist/favorite.dart @@ -0,0 +1,122 @@ +part of '../spotify.dart'; + +class FavoritePlaylistsState extends PaginatedState { + FavoritePlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FavoritePlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return FavoritePlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FavoritePlaylistsNotifier + extends PaginatedAsyncNotifier { + FavoritePlaylistsNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final playlists = await spotify.playlists.me.getPage( + limit, + offset, + ); + + return playlists.items?.toList() ?? []; + } + + @override + build() async { + ref.watch(spotifyProvider); + final playlists = await fetch(0, 20); + + return FavoritePlaylistsState( + items: playlists, + offset: 0, + limit: 20, + hasMore: playlists.length == 20, + ); + } + + Future addFavorite(PlaylistSimple playlist) async { + await update((state) async { + await spotify.playlists.followPlaylist(playlist.id!); + return state.copyWith( + items: [...state.items, playlist], + ); + }); + + ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + } + + Future removeFavorite(PlaylistSimple playlist) async { + await update((state) async { + await spotify.playlists.unfollowPlaylist(playlist.id!); + return state.copyWith( + items: state.items.where((e) => e.id != playlist.id).toList(), + ); + }); + + ref.invalidate(isFavoritePlaylistProvider(playlist.id!)); + } + + Future addTracks(String playlistId, List trackIds) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await spotify.playlists.addTracks( + trackIds.map((id) => 'spotify:track:$id').toList(), + playlistId, + ); + + ref.invalidate(playlistTracksProvider(playlistId)); + } + + Future removeTracks(String playlistId, List trackIds) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await spotify.playlists.removeTracks( + trackIds.map((id) => 'spotify:track:$id').toList(), + playlistId, + ); + + ref.invalidate(playlistTracksProvider(playlistId)); + } +} + +final favoritePlaylistsProvider = + AsyncNotifierProvider( + () => FavoritePlaylistsNotifier(), +); + +final isFavoritePlaylistProvider = FutureProvider.family( + (ref, id) async { + final spotify = ref.watch(spotifyProvider); + final me = ref.watch(meProvider); + + if (me.value == null) { + return false; + } + + final follows = + await spotify.playlists.followedByUsers(id, [me.value!.id!]); + + return follows[me.value!.id!] ?? false; + }, +); diff --git a/lib/provider/spotify/playlist/featured.dart b/lib/provider/spotify/playlist/featured.dart new file mode 100644 index 00000000..69057e5d --- /dev/null +++ b/lib/provider/spotify/playlist/featured.dart @@ -0,0 +1,58 @@ +part of '../spotify.dart'; + +class FeaturedPlaylistsState extends PaginatedState { + FeaturedPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + FeaturedPlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return FeaturedPlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class FeaturedPlaylistsNotifier + extends PaginatedAsyncNotifier { + FeaturedPlaylistsNotifier() : super(); + + @override + fetch(int offset, int limit) async { + final playlists = await spotify.playlists.featured.getPage( + limit, + offset, + ); + + return playlists.items?.toList() ?? []; + } + + @override + build() async { + ref.watch(spotifyProvider); + final playlists = await fetch(0, 20); + + return FeaturedPlaylistsState( + items: playlists, + offset: 0, + limit: 20, + hasMore: playlists.length == 20, + ); + } +} + +final featuredPlaylistsProvider = + AsyncNotifierProvider( + () => FeaturedPlaylistsNotifier(), +); diff --git a/lib/provider/spotify/playlist/generate.dart b/lib/provider/spotify/playlist/generate.dart new file mode 100644 index 00000000..15447b54 --- /dev/null +++ b/lib/provider/spotify/playlist/generate.dart @@ -0,0 +1,40 @@ +part of '../spotify.dart'; + +final generatePlaylistProvider = FutureProvider.autoDispose + .family, GeneratePlaylistProviderInput>( + (ref, input) async { + final spotify = ref.watch(spotifyProvider); + final market = ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + + final recommendation = await spotify.recommendations + .get( + limit: input.limit, + seedArtists: input.seedArtists?.toList(), + seedGenres: input.seedGenres?.toList(), + seedTracks: input.seedTracks?.toList(), + market: market, + max: (input.max?.toJson()?..removeWhere((key, value) => value == null)) + ?.cast(), + min: (input.min?.toJson()?..removeWhere((key, value) => value == null)) + ?.cast(), + target: (input.target?.toJson() + ?..removeWhere((key, value) => value == null)) + ?.cast(), + ) + .catchError((e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + return Recommendations(); + }); + + if (recommendation.tracks?.isEmpty ?? true) { + return []; + } + + final tracks = await spotify.tracks + .list(recommendation.tracks!.map((e) => e.id!).toList()); + + return tracks.toList(); + }, +); diff --git a/lib/provider/spotify/playlist/liked.dart b/lib/provider/spotify/playlist/liked.dart new file mode 100644 index 00000000..52463d3d --- /dev/null +++ b/lib/provider/spotify/playlist/liked.dart @@ -0,0 +1,49 @@ +part of '../spotify.dart'; + +class LikedTracksNotifier extends AsyncNotifier> with Persistence { + LikedTracksNotifier() { + load(); + } + + @override + FutureOr> build() async { + final spotify = ref.watch(spotifyProvider); + final savedTracked = await spotify.tracks.me.saved.all(); + + return savedTracked.map((e) => e.track!).toList(); + } + + Future toggleFavorite(Track track) async { + if (state.value == null) return; + final spotify = ref.read(spotifyProvider); + + await update((tracks) async { + final isLiked = tracks.map((e) => e.id).contains(track.id); + + if (isLiked) { + await spotify.tracks.me.removeOne(track.id!); + return tracks.where((e) => e.id != track.id).toList(); + } else { + await spotify.tracks.me.saveOne(track.id!); + return [track, ...tracks]; + } + }); + } + + @override + FutureOr> fromJson(Map json) { + return (json['tracks'] as List).map((e) => Track.fromJson(e)).toList(); + } + + @override + Map toJson(List data) { + return { + 'tracks': data.map((e) => e.toJson()).toList(), + }; + } +} + +final likedTracksProvider = + AsyncNotifierProvider>( + () => LikedTracksNotifier(), +); diff --git a/lib/provider/spotify/playlist/playlist.dart b/lib/provider/spotify/playlist/playlist.dart new file mode 100644 index 00000000..fd420cd9 --- /dev/null +++ b/lib/provider/spotify/playlist/playlist.dart @@ -0,0 +1,90 @@ +part of '../spotify.dart'; + +typedef PlaylistInput = ({ + String playlistName, + bool? public, + bool? collaborative, + String? description, + String? base64Image, +}); + +class PlaylistNotifier extends FamilyAsyncNotifier { + @override + FutureOr build(String arg) { + final spotify = ref.watch(spotifyProvider); + return spotify.playlists.get(arg); + } + + Future create(PlaylistInput input, [ValueChanged? onError]) async { + if (state is AsyncLoading) return; + state = const AsyncLoading(); + + final spotify = ref.read(spotifyProvider); + final me = ref.read(meProvider); + + if (me.value == null) return; + + state = await AsyncValue.guard(() async { + try { + final playlist = await spotify.playlists.createPlaylist( + me.value!.id!, + input.playlistName, + collaborative: input.collaborative, + description: input.description, + public: input.public, + ); + + if (input.base64Image != null) { + await spotify.playlists.updatePlaylistImage( + playlist.id!, + input.base64Image!, + ); + } + + return playlist; + } catch (e) { + onError?.call(e); + rethrow; + } + }); + + ref.invalidate(favoritePlaylistsProvider); + } + + Future modify(PlaylistInput input, [ValueChanged? onError]) async { + if (state.value == null) return; + + final spotify = ref.read(spotifyProvider); + + await update((state) async { + try { + await spotify.playlists.updatePlaylist( + state.id!, + input.playlistName, + collaborative: input.collaborative, + description: input.description, + public: input.public, + ); + + if (input.base64Image != null) { + await spotify.playlists.updatePlaylistImage( + state.id!, + input.base64Image!, + ); + } + + return spotify.playlists.get(state.id!); + } catch (e) { + onError?.call(e); + rethrow; + } + }); + + ref.invalidate(favoritePlaylistsProvider); + } +} + +final playlistProvider = + AsyncNotifierProvider.family( + () => PlaylistNotifier(), +); diff --git a/lib/provider/spotify/playlist/tracks.dart b/lib/provider/spotify/playlist/tracks.dart new file mode 100644 index 00000000..1803f6fc --- /dev/null +++ b/lib/provider/spotify/playlist/tracks.dart @@ -0,0 +1,64 @@ +part of '../spotify.dart'; + +class PlaylistTracksState extends PaginatedState { + PlaylistTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + PlaylistTracksState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return PlaylistTracksState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class PlaylistTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< + Track, PlaylistTracksState, String> { + PlaylistTracksNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + final tracks = await spotify.playlists + .getTracksByPlaylistId(arg) + .getPage(limit, offset); + + /// Filter out tracks with null id because some personal playlists + /// may contain local tracks that are not available in the Spotify catalog + return tracks.items + ?.where((track) => track.id != null && track.type == "track") + .toList() ?? + []; + } + + @override + build(arg) async { + ref.cacheFor(); + + ref.watch(spotifyProvider); + final tracks = await fetch(arg, 0, 20); + + return PlaylistTracksState( + items: tracks, + offset: 0, + limit: 20, + hasMore: tracks.length == 20, + ); + } +} + +final playlistTracksProvider = AutoDisposeAsyncNotifierProviderFamily< + PlaylistTracksNotifier, PlaylistTracksState, String>( + () => PlaylistTracksNotifier(), +); diff --git a/lib/provider/spotify/search/search.dart b/lib/provider/spotify/search/search.dart new file mode 100644 index 00000000..bd97f08b --- /dev/null +++ b/lib/provider/spotify/search/search.dart @@ -0,0 +1,76 @@ +part of '../spotify.dart'; + +final searchTermStateProvider = StateProvider.autoDispose( + (ref) { + ref.cacheFor(const Duration(minutes: 2)); + return ""; + }, +); + +class SearchState extends PaginatedState { + SearchState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + SearchState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return SearchState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class SearchNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier, SearchType> { + SearchNotifier() : super(); + + @override + fetch(arg, offset, limit) async { + if (state.value == null) return []; + final results = await spotify.search + .get( + ref.read(searchTermStateProvider), + types: [arg], + market: ref.read(userPreferencesProvider).recommendationMarket, + ) + .getPage(limit, offset); + + return results.expand((e) => e.items ?? []).toList().cast(); + } + + @override + build(arg) async { + ref.cacheFor(const Duration(minutes: 2)); + + ref.watch(searchTermStateProvider); + ref.watch(spotifyProvider); + ref.watch( + userPreferencesProvider.select((value) => value.recommendationMarket), + ); + + final results = await fetch(arg, 0, 10); + + return SearchState( + items: results, + offset: 0, + limit: 10, + hasMore: results.length == 10, + ); + } +} + +final searchProvider = AsyncNotifierProvider.autoDispose + .family( + () => SearchNotifier(), +); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart new file mode 100644 index 00000000..ea28b6d8 --- /dev/null +++ b/lib/provider/spotify/spotify.dart @@ -0,0 +1,73 @@ +library spotify; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:intl/intl.dart'; +import 'package:spotify/spotify.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +// ignore: depend_on_referenced_packages, implementation_imports +import 'package:riverpod/src/async_notifier.dart'; +import 'package:spotube/extensions/map.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/lyrics.dart'; +import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/wikipedia/wikipedia.dart'; +import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:http/http.dart' as http; +import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:wikipedia_api/wikipedia_api.dart'; + +part 'album/favorite.dart'; +part 'album/tracks.dart'; +part 'album/releases.dart'; +part 'album/is_saved.dart'; + +part 'artist/artist.dart'; +part 'artist/is_following.dart'; +part 'artist/following.dart'; +part 'artist/top_tracks.dart'; +part 'artist/albums.dart'; +part 'artist/wikipedia.dart'; +part 'artist/related.dart'; + +part 'category/genres.dart'; +part 'category/categories.dart'; +part 'category/playlists.dart'; + +part 'lyrics/synced.dart'; + +part 'playlist/favorite.dart'; +part 'playlist/playlist.dart'; +part 'playlist/liked.dart'; +part 'playlist/tracks.dart'; +part 'playlist/featured.dart'; +part 'playlist/generate.dart'; + +part 'search/search.dart'; + +part 'user/me.dart'; +part 'user/friends.dart'; + +part 'tracks/track.dart'; + +part 'views/view.dart'; + +part 'utils/mixin.dart'; +part 'utils/state.dart'; +part 'utils/provider.dart'; +part 'utils/persistence.dart'; +part 'utils/async.dart'; + +part 'utils/provider/paginated.dart'; +part 'utils/provider/cursor.dart'; +part 'utils/provider/paginated_family.dart'; +part 'utils/provider/cursor_family.dart'; diff --git a/lib/provider/spotify/tracks/track.dart b/lib/provider/spotify/tracks/track.dart new file mode 100644 index 00000000..e3913b1f --- /dev/null +++ b/lib/provider/spotify/tracks/track.dart @@ -0,0 +1,10 @@ +part of '../spotify.dart'; + +final trackProvider = + FutureProvider.autoDispose.family((ref, id) async { + ref.cacheFor(); + + final spotify = ref.watch(spotifyProvider); + + return spotify.tracks.get(id); +}); diff --git a/lib/provider/spotify/user/friends.dart b/lib/provider/spotify/user/friends.dart new file mode 100644 index 00000000..b9cc0f46 --- /dev/null +++ b/lib/provider/spotify/user/friends.dart @@ -0,0 +1,7 @@ +part of '../spotify.dart'; + +final friendsProvider = FutureProvider((ref) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + + return customSpotify.getFriendActivity(); +}); diff --git a/lib/provider/spotify/user/me.dart b/lib/provider/spotify/user/me.dart new file mode 100644 index 00000000..c5949e1f --- /dev/null +++ b/lib/provider/spotify/user/me.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +final meProvider = FutureProvider((ref) async { + final spotify = ref.watch(spotifyProvider); + return spotify.me.get(); +}); diff --git a/lib/provider/spotify/utils/async.dart b/lib/provider/spotify/utils/async.dart new file mode 100644 index 00000000..1040d682 --- /dev/null +++ b/lib/provider/spotify/utils/async.dart @@ -0,0 +1,5 @@ +part of '../spotify.dart'; + +extension PaginationExtension on AsyncValue { + bool get isLoadingNextPage => this is AsyncData && this is AsyncLoadingNext; +} diff --git a/lib/provider/spotify/utils/mixin.dart b/lib/provider/spotify/utils/mixin.dart new file mode 100644 index 00000000..0da14c6f --- /dev/null +++ b/lib/provider/spotify/utils/mixin.dart @@ -0,0 +1,24 @@ +part of '../spotify.dart'; + +// ignore: invalid_use_of_internal_member +mixin SpotifyMixin on AsyncNotifierBase { + SpotifyApi get spotify => ref.read(spotifyProvider); +} + +extension on AutoDisposeAsyncNotifierProviderRef { + // When invoked keeps your provider alive for [duration] + void cacheFor([Duration duration = const Duration(minutes: 5)]) { + final link = keepAlive(); + final timer = Timer(duration, () => link.close()); + onDispose(() => timer.cancel()); + } +} + +extension on AutoDisposeRef { + // When invoked keeps your provider alive for [duration] + void cacheFor([Duration duration = const Duration(minutes: 5)]) { + final link = keepAlive(); + final timer = Timer(duration, () => link.close()); + onDispose(() => timer.cancel()); + } +} diff --git a/lib/provider/spotify/utils/persistence.dart b/lib/provider/spotify/utils/persistence.dart new file mode 100644 index 00000000..14d3c940 --- /dev/null +++ b/lib/provider/spotify/utils/persistence.dart @@ -0,0 +1,40 @@ +part of '../spotify.dart'; + +// ignore: invalid_use_of_internal_member +mixin Persistence on BuildlessAsyncNotifier { + LazyBox get store => Hive.lazyBox("spotube_cache"); + + FutureOr fromJson(Map json); + Map toJson(T data); + + FutureOr onInit() {} + + Future load() async { + final json = await store.get(runtimeType.toString()); + if (json != null || + (json is Map && json.entries.isNotEmpty) || + (json is List && json.isNotEmpty)) { + state = AsyncData( + await fromJson( + PersistedStateNotifier.castNestedJson(json), + ), + ); + } + + await onInit(); + } + + Future save() async { + await store.put( + runtimeType.toString(), + state.value == null ? null : toJson(state.value as T), + ); + } + + @override + set state(AsyncValue value) { + if (state == value) return; + super.state = value; + save(); + } +} diff --git a/lib/provider/spotify/utils/provider.dart b/lib/provider/spotify/utils/provider.dart new file mode 100644 index 00000000..50458c3a --- /dev/null +++ b/lib/provider/spotify/utils/provider.dart @@ -0,0 +1,6 @@ +part of '../spotify.dart'; + +// ignore: subtype_of_sealed_class +class AsyncLoadingNext extends AsyncData { + const AsyncLoadingNext(super.value); +} diff --git a/lib/provider/spotify/utils/provider/cursor.dart b/lib/provider/spotify/utils/provider/cursor.dart new file mode 100644 index 00000000..c241827e --- /dev/null +++ b/lib/provider/spotify/utils/provider/cursor.dart @@ -0,0 +1,56 @@ +part of '../../spotify.dart'; + +mixin CursorPaginatedAsyncNotifierMixin> + // ignore: invalid_use_of_internal_member + on AsyncNotifierBase { + Future<(List items, String nextCursor)> fetch(String? offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch(state.offset, state.limit); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class CursorPaginatedAsyncNotifier> extends AsyncNotifier + with CursorPaginatedAsyncNotifierMixin, SpotifyMixin {} + +abstract class AutoDisposeCursorPaginatedAsyncNotifier> extends AutoDisposeAsyncNotifier + with CursorPaginatedAsyncNotifierMixin, SpotifyMixin {} diff --git a/lib/provider/spotify/utils/provider/cursor_family.dart b/lib/provider/spotify/utils/provider/cursor_family.dart new file mode 100644 index 00000000..ea8577de --- /dev/null +++ b/lib/provider/spotify/utils/provider/cursor_family.dart @@ -0,0 +1,113 @@ +part of '../../spotify.dart'; + +abstract class FamilyCursorPaginatedAsyncNotifier< + K, + T extends CursorPaginatedState, + A> extends FamilyAsyncNotifier with SpotifyMixin { + Future<(List items, String nextCursor)> fetch( + A arg, + String? offset, + int limit, + ); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(arg, state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset, + state.limit, + ); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class AutoDisposeFamilyCursorPaginatedAsyncNotifier< + K, + T extends CursorPaginatedState, + A> extends AutoDisposeFamilyAsyncNotifier with SpotifyMixin { + Future<(List items, String nextCursor)> fetch( + A arg, + String? offset, + int limit, + ); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch(arg, state.value!.offset, state.value!.limit); + return state.value!.copyWith( + hasMore: items.$1.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items.$1, + ], + offset: items.$2, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset, + state.limit, + ); + + hasMore = items.$1.length == state.limit; + return state.copyWith( + items: [...state.items, ...items.$1], + offset: items.$2, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} diff --git a/lib/provider/spotify/utils/provider/paginated.dart b/lib/provider/spotify/utils/provider/paginated.dart new file mode 100644 index 00000000..30b66e67 --- /dev/null +++ b/lib/provider/spotify/utils/provider/paginated.dart @@ -0,0 +1,63 @@ +part of '../../spotify.dart'; + +mixin PaginatedAsyncNotifierMixin> + // ignore: invalid_use_of_internal_member + on AsyncNotifierBase { + Future> fetch(int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch( + state.value!.offset + state.value!.limit, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: items.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items, + ], + offset: state.value!.offset + state.value!.limit, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + state.offset + state.limit, + state.limit, + ); + + hasMore = items.length == state.limit; + return state.copyWith( + items: [...state.items, ...items], + offset: state.offset + state.limit, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class PaginatedAsyncNotifier> + extends AsyncNotifier + with PaginatedAsyncNotifierMixin, SpotifyMixin {} + +abstract class AutoDisposePaginatedAsyncNotifier> + extends AutoDisposeAsyncNotifier + with PaginatedAsyncNotifierMixin, SpotifyMixin {} diff --git a/lib/provider/spotify/utils/provider/paginated_family.dart b/lib/provider/spotify/utils/provider/paginated_family.dart new file mode 100644 index 00000000..84c6ba20 --- /dev/null +++ b/lib/provider/spotify/utils/provider/paginated_family.dart @@ -0,0 +1,113 @@ +part of '../../spotify.dart'; + +abstract class FamilyPaginatedAsyncNotifier< + K, + T extends BasePaginatedState, + A> extends FamilyAsyncNotifier with SpotifyMixin { + Future> fetch(A arg, int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch( + arg, + state.value!.offset + state.value!.limit, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: items.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items, + ], + offset: state.value!.offset + state.value!.limit, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset + state.limit, + state.limit, + ); + + hasMore = items.length == state.limit; + return state.copyWith( + items: [...state.items, ...items], + offset: state.offset + state.limit, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} + +abstract class AutoDisposeFamilyPaginatedAsyncNotifier< + K, + T extends BasePaginatedState, + A> extends AutoDisposeFamilyAsyncNotifier with SpotifyMixin { + Future> fetch(A arg, int offset, int limit); + + Future fetchMore() async { + if (state.value == null || !state.value!.hasMore) return; + + state = AsyncLoadingNext(state.asData!.value); + + state = await AsyncValue.guard( + () async { + final items = await fetch( + arg, + state.value!.offset + state.value!.limit, + state.value!.limit, + ); + return state.value!.copyWith( + hasMore: items.length == state.value!.limit, + items: [ + ...state.value!.items, + ...items, + ], + offset: state.value!.offset + state.value!.limit, + ) as T; + }, + ); + } + + Future> fetchAll() async { + if (state.value == null) return []; + if (!state.value!.hasMore) return state.value!.items; + + bool hasMore = true; + while (hasMore) { + await update((state) async { + final items = await fetch( + arg, + state.offset + state.limit, + state.limit, + ); + + hasMore = items.length == state.limit; + return state.copyWith( + items: [...state.items, ...items], + offset: state.offset + state.limit, + hasMore: hasMore, + ) as T; + }); + } + + return state.value!.items; + } +} diff --git a/lib/provider/spotify/utils/state.dart b/lib/provider/spotify/utils/state.dart new file mode 100644 index 00000000..4b79ac7d --- /dev/null +++ b/lib/provider/spotify/utils/state.dart @@ -0,0 +1,56 @@ +part of '../spotify.dart'; + +abstract class BasePaginatedState { + final List items; + final Cursor offset; + final int limit; + final bool hasMore; + + BasePaginatedState({ + required this.items, + required this.offset, + required this.limit, + required this.hasMore, + }); + + BasePaginatedState copyWith({ + List? items, + Cursor? offset, + int? limit, + bool? hasMore, + }); +} + +abstract class PaginatedState extends BasePaginatedState { + PaginatedState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + PaginatedState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }); +} + +abstract class CursorPaginatedState extends BasePaginatedState { + CursorPaginatedState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + CursorPaginatedState copyWith({ + List? items, + String? offset, + int? limit, + bool? hasMore, + }); +} diff --git a/lib/provider/spotify/views/view.dart b/lib/provider/spotify/views/view.dart new file mode 100644 index 00000000..f1af998b --- /dev/null +++ b/lib/provider/spotify/views/view.dart @@ -0,0 +1,19 @@ +part of '../spotify.dart'; + +final viewProvider = FutureProvider.family, String>( + (ref, viewName) async { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + final market = ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + final locale = ref.watch( + userPreferencesProvider.select((s) => s.locale), + ); + + return customSpotify.getView( + viewName, + market: market, + locale: Intl.canonicalizedLocale(locale.toString()), + ); + }, +); diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart index 436627e6..2dfef362 100644 --- a/lib/services/audio_services/linux_audio_service.dart +++ b/lib/services/audio_services/linux_audio_service.dart @@ -258,7 +258,7 @@ class _MprisMediaPlayer2Player extends DBusObject { /// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus Future getLoopStatus() async { - final loopMode = switch (await audioPlayer.loopMode) { + final loopMode = switch (audioPlayer.loopMode) { PlaybackLoopMode.all => "Playlist", PlaybackLoopMode.one => "Track", PlaybackLoopMode.none => "None", diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 833df89c..d259317e 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -137,7 +137,7 @@ class MobileAudioService extends BaseAudioHandler { shuffleMode: await audioPlayer.isShuffled == true ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, - repeatMode: (await audioPlayer.loopMode).toAudioServiceRepeatMode(), + repeatMode: (audioPlayer.loopMode).toAudioServiceRepeatMode(), processingState: playlist.isFetching == true ? AudioProcessingState.loading : AudioProcessingState.ready, diff --git a/lib/services/connectivity_adapter.dart b/lib/services/connectivity_adapter.dart index c628f2f7..1a3835ee 100644 --- a/lib/services/connectivity_adapter.dart +++ b/lib/services/connectivity_adapter.dart @@ -2,17 +2,17 @@ import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:fl_query/fl_query.dart'; import 'package:flutter/widgets.dart'; -class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter - with WidgetsBindingObserver { +class ConnectionCheckerService with WidgetsBindingObserver { final _connectionStreamController = StreamController.broadcast(); final Dio dio; - FlQueryInternetConnectionCheckerAdapter() - : dio = Dio(), - super() { + static final _instance = ConnectionCheckerService._(); + + static ConnectionCheckerService get instance => _instance; + + ConnectionCheckerService._() : dio = Dio() { Timer? timer; onConnectivityChanged.listen((connected) { @@ -100,15 +100,16 @@ class FlQueryInternetConnectionCheckerAdapter extends ConnectivityAdapter await isVpnActive(); // when VPN is active that means we are connected } - @override + bool isConnectedSync = false; + Future get isConnected async { final connected = await _isConnected(); + isConnectedSync = connected; if (connected != isConnectedSync /*previous value*/) { _connectionStreamController.add(connected); } return connected; } - @override Stream get onConnectivityChanged => _connectionStreamController.stream; } diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart index d7a42430..dbb96791 100644 --- a/lib/services/download_manager/download_manager.dart +++ b/lib/services/download_manager/download_manager.dart @@ -207,7 +207,7 @@ class DownloadManager { // Do nothing return _cache[downloadRequest.url]!; } else { - _queue.remove(_cache[downloadRequest.url]); + _queue.remove(_cache[downloadRequest.url]?.request); } } @@ -286,21 +286,21 @@ class DownloadManager { } Future pauseBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { pauseDownload(element); - }); + } } Future cancelBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { cancelDownload(element); - }); + } } Future resumeBatchDownloads(List urls) async { - urls.forEach((element) { + for (var element in urls) { resumeDownload(element); - }); + } } ValueNotifier getBatchDownloadProgress(List urls) { @@ -315,9 +315,9 @@ class DownloadManager { return getDownload(urls.first)?.progress ?? progress; } - var progressMap = Map(); + var progressMap = {}; - urls.forEach((url) { + for (var url in urls) { DownloadTask? task = getDownload(url); if (task != null) { @@ -328,29 +328,27 @@ class DownloadManager { progress.value = progressMap.values.sum / total; } - var progressListener; - progressListener = () { + void progressListener() { progressMap[url] = task.progress.value; progress.value = progressMap.values.sum / total; - }; + } task.progress.addListener(progressListener); - var listener; - listener = () { + void listener() { if (task.status.value.isCompleted) { progressMap[url] = 1.0; progress.value = progressMap.values.sum / total; task.status.removeListener(listener); task.progress.removeListener(progressListener); } - }; + } task.status.addListener(listener); } else { total--; } - }); + } return progress; } @@ -374,8 +372,7 @@ class DownloadManager { } } - var listener; - listener = () { + void listener() { if (task.status.value.isCompleted) { completed++; @@ -384,7 +381,7 @@ class DownloadManager { task.status.removeListener(listener); } } - }; + } task.status.addListener(listener); } else { diff --git a/lib/services/download_manager/download_task.dart b/lib/services/download_manager/download_task.dart index 5d57a655..d65f167e 100644 --- a/lib/services/download_manager/download_task.dart +++ b/lib/services/download_manager/download_task.dart @@ -21,13 +21,14 @@ class DownloadTask { completer.complete(status.value); } - var listener; - listener = () { + void listener() { if (status.value.isCompleted) { completer.complete(status.value); status.removeListener(listener); } - }; + } + + ; status.addListener(listener); diff --git a/lib/services/mutations/album.dart b/lib/services/mutations/album.dart deleted file mode 100644 index 144b6a8f..00000000 --- a/lib/services/mutations/album.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; - -class AlbumMutations { - const AlbumMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String albumId, { - List? refreshQueries, - List? refreshInfiniteQueries, - MutationOnDataFn? onData, - }) { - return useSpotifyMutation( - "toggle-album-like/$albumId", - (isLiked, spotify) async { - if (isLiked) { - await spotify.me.removeAlbums([albumId]); - } else { - await spotify.me.saveAlbums([albumId]); - } - return !isLiked; - }, - ref: ref, - refreshQueries: refreshQueries, - refreshInfiniteQueries: refreshInfiniteQueries, - onData: onData, - ); - } -} diff --git a/lib/services/mutations/mutations.dart b/lib/services/mutations/mutations.dart deleted file mode 100644 index 28670486..00000000 --- a/lib/services/mutations/mutations.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:spotube/services/mutations/album.dart'; -import 'package:spotube/services/mutations/playlist.dart'; -import 'package:spotube/services/mutations/track.dart'; - -class _UseMutations { - const _UseMutations._(); - final playlist = const PlaylistMutations(); - final album = const AlbumMutations(); - final track = const TrackMutations(); -} - -const useMutations = _UseMutations._(); diff --git a/lib/services/mutations/playlist.dart b/lib/services/mutations/playlist.dart deleted file mode 100644 index f480c565..00000000 --- a/lib/services/mutations/playlist.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; -import 'package:spotube/services/queries/queries.dart'; - -typedef PlaylistCRUDVariables = ({ - String playlistName, - bool? public, - bool? collaborative, - String? description, - String? base64Image, -}); - -class PlaylistMutations { - const PlaylistMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String playlistId, { - List? refreshQueries, - List? refreshInfiniteQueries, - ValueChanged? onData, - }) { - return useSpotifyMutation( - "toggle-playlist-like/$playlistId", - (isLiked, spotify) async { - if (isLiked) { - await spotify.playlists.unfollowPlaylist(playlistId); - } else { - await spotify.playlists.followPlaylist(playlistId); - } - return !isLiked; - }, - ref: ref, - refreshQueries: refreshQueries, - refreshInfiniteQueries: [ - ...?refreshInfiniteQueries, - "current-user-playlists", - ], - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } - - Mutation removeTrackOf( - WidgetRef ref, - String playlistId, - ) { - return useSpotifyMutation( - "remove-track-from-playlist/$playlistId", - (trackId, spotify) async { - await spotify.playlists.removeTracks([trackId], playlistId); - return true; - }, - ref: ref, - refreshQueries: ["playlist-tracks/$playlistId"], - ); - } - - Mutation create( - WidgetRef ref, { - List? trackIds, - ValueChanged? onError, - ValueChanged? onData, - }) { - final me = useQueries.user.me(ref); - return useSpotifyMutation( - "create-playlist", - (variable, spotify) async { - final playlist = await spotify.playlists.createPlaylist( - me.data!.id!, - variable.playlistName, - collaborative: variable.collaborative, - description: variable.description, - public: variable.public, - ); - - if (variable.base64Image != null) { - await spotify.playlists.updatePlaylistImage( - playlist.id!, - variable.base64Image!, - ); - } - - if (trackIds != null && trackIds.isNotEmpty) { - await spotify.playlists.addTracks( - trackIds.map((id) => "spotify:track:$id").toList(), - playlist.id!, - ); - } - - return playlist; - }, - refreshInfiniteQueries: ["current-user-playlists"], - refreshQueries: ["current-user-all-playlists"], - ref: ref, - onError: (error, recoveryData) { - onError?.call(error); - }, - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } - - Mutation update( - WidgetRef ref, { - String? playlistId, - ValueChanged? onError, - ValueChanged? onData, - }) { - return useSpotifyMutation( - "update-playlist/$playlistId", - (variable, spotify) async { - if (playlistId == null) return; - await spotify.playlists.updatePlaylist( - playlistId, - variable.playlistName, - collaborative: variable.collaborative, - description: variable.description, - public: variable.public, - ); - if (variable.base64Image != null) { - await spotify.playlists.updatePlaylistImage( - playlistId, - variable.base64Image!, - ); - } - }, - refreshInfiniteQueries: [ - "playlist/$playlistId", - "current-user-playlists", - ], - refreshQueries: ["current-user-all-playlists"], - ref: ref, - onError: (error, recoveryData) { - onError?.call(error); - }, - onData: (data, recoveryData) { - onData?.call(data); - }, - ); - } -} diff --git a/lib/services/mutations/track.dart b/lib/services/mutations/track.dart deleted file mode 100644 index f8208b5e..00000000 --- a/lib/services/mutations/track.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/hooks/spotify/use_spotify_mutation.dart'; - -class TrackMutations { - const TrackMutations(); - - Mutation toggleFavorite( - WidgetRef ref, - String trackId, { - MutationOnMutationFn? onMutate, - MutationOnDataFn? onData, - MutationOnErrorFn? onError, - }) { - return useSpotifyMutation( - 'toggle-track-like/$trackId', - (isLiked, spotify) async { - if (isLiked) { - await spotify.tracks.me.removeOne(trackId); - } else { - await spotify.tracks.me.saveOne(trackId); - } - return !isLiked; - }, - ref: ref, - onData: onData, - onMutate: onMutate, - refreshQueries: ["playlist-tracks/user-liked-tracks"], - onError: onError, - ); - } -} diff --git a/lib/services/queries/album.dart b/lib/services/queries/album.dart deleted file mode 100644 index 0cc10256..00000000 --- a/lib/services/queries/album.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class AlbumQueries { - const AlbumQueries(); - - InfiniteQuery, dynamic, int> ofMine(WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, int>( - "current-user-albums", - (page, spotify) { - return spotify.me.savedAlbums().getPage( - 20, - page * 20, - ); - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) => - (lastPageData.items?.length ?? 0) < 20 || lastPageData.isLast - ? null - : lastPage + 1, - ref: ref, - ); - } - - static final tracksOfJob = InfiniteQueryJob.withVariableKey< - List, - dynamic, - int, - ({ - SpotifyApi spotify, - AlbumSimple album, - })>( - baseQueryKey: "album-tracks", - initialPage: 0, - task: (albumId, page, args) async { - final res = - await args!.spotify.albums.tracks(albumId).getPage(20, page * 20); - return res.items - ?.map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, args.album)) - .toList() ?? - []; - }, - nextPage: (lastPage, lastPageData) { - if (lastPageData.length < 20) { - return null; - } - return lastPage + 1; - }, - ); - - InfiniteQuery, dynamic, int> tracksOf( - WidgetRef ref, - AlbumSimple album, - ) { - final spotify = ref.watch(spotifyProvider); - - return useInfiniteQueryJob( - job: tracksOfJob(album.id!), - args: (spotify: spotify, album: album), - ); - } - - Query isSavedForMe( - WidgetRef ref, - String album, - ) { - return useSpotifyQuery( - "is-saved-for-me/$album", - (spotify) { - return spotify.me - .containsSavedAlbums([album]).then((value) => value[album]); - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> newReleases(WidgetRef ref) { - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - - return useSpotifyInfiniteQuery, dynamic, int>( - "new-releases", - (pageParam, spotify) async { - try { - final albums = await spotify.browse - .newReleases(country: market) - .getPage(50, pageParam); - - return albums; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - }, - ref: ref, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast) { - return null; - } - return lastPageData.nextOffset; - }, - ); - } -} diff --git a/lib/services/queries/artist.dart b/lib/services/queries/artist.dart deleted file mode 100644 index 1b939c82..00000000 --- a/lib/services/queries/artist.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/wikipedia/wikipedia.dart'; -import 'package:wikipedia_api/wikipedia_api.dart'; - -class ArtistQueries { - const ArtistQueries(); - - Query get( - WidgetRef ref, - String artist, - ) { - return useSpotifyQuery( - "artist-profile/$artist", - (spotify) => spotify.artists.get(artist), - ref: ref, - ); - } - - InfiniteQuery, dynamic, String> followedByMe( - WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, String>( - "user-following-artists", - (pageParam, spotify) async { - return spotify.me - .following(FollowingType.artist) - .getPage(15, pageParam); - }, - initialPage: "", - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 15) { - return null; - } - return lastPageData.after; - }, - ref: ref, - ); - } - - Query, dynamic> followedByMeAll(WidgetRef ref) { - return useSpotifyQuery( - "user-following-artists-all", - (spotify) async { - CursorPage? page = - await spotify.me.following(FollowingType.artist).getPage(50); - - final following = []; - - if (page.isLast == true) { - return page.items?.toList() ?? []; - } - - following.addAll(page.items ?? []); - while (page?.isLast != true) { - page = await spotify.me - .following(FollowingType.artist) - .getPage(50, page?.after ?? ''); - following.addAll(page.items ?? []); - } - - return following; - }, - ref: ref, - ); - } - - Query doIFollow( - WidgetRef ref, - String artist, - ) { - return useSpotifyQuery( - "user-follows-artists-query/$artist", - (spotify) async { - final result = await spotify.me.checkFollowing( - FollowingType.artist, - [artist], - ); - return result[artist]; - }, - ref: ref, - ); - } - - Query, dynamic> topTracksOf( - WidgetRef ref, - String artist, - ) { - final preferences = ref.watch(userPreferencesProvider); - return useSpotifyQuery, dynamic>( - "artist-top-track-query/$artist", - (spotify) { - return spotify.artists - .topTracks(artist, preferences.recommendationMarket); - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> albumsOf( - WidgetRef ref, - String artist, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "artist-albums/$artist", - (pageParam, spotify) async { - return spotify.artists.albums(artist).getPage(5, pageParam); - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> relatedArtistsOf( - WidgetRef ref, - String artist, - ) { - return useSpotifyQuery, dynamic>( - "artist-related-artist-query/$artist", - (spotify) { - return spotify.artists.relatedArtists(artist); - }, - ref: ref, - ); - } - - Query wikipediaSummary(ArtistSimple artist) { - return useQuery( - "artist-wikipedia-query/${artist.id}", - () async { - final query = artist.name!.replaceAll(" ", "_"); - final res = await wikipedia.pageContent.pageSummaryTitleGet(query); - if (res?.type != "standard") { - return await wikipedia.pageContent - .pageSummaryTitleGet("${query}_(singer)"); - } - return res; - }, - ); - } -} diff --git a/lib/services/queries/category.dart b/lib/services/queries/category.dart deleted file mode 100644 index d520b909..00000000 --- a/lib/services/queries/category.dart +++ /dev/null @@ -1,120 +0,0 @@ -import 'package:fl_query/fl_query.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/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, dynamic> listAll( - WidgetRef ref, - Market recommendationMarket, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final locale = useContext().l10n.localeName; - final query = useSpotifyQuery, 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, dynamic, int> list( - WidgetRef ref, - Market recommendationMarket, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final locale = useContext().l10n.localeName; - return useSpotifyInfiniteQuery, dynamic, int>( - "category-playlists", - (pageParam, spotify) async { - final categories = await spotify.categories - .list( - country: recommendationMarket, - locale: locale, - ) - .getPage(8, pageParam); - - return categories; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 8) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> playlistsOf( - WidgetRef ref, - String category, - ) { - ref.watch(userPreferencesProvider.select((s) => s.locale)); - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - final locale = useContext().l10n.localeName; - return useSpotifyInfiniteQuery, dynamic, int>( - "category-playlists/$category", - (pageParam, spotify) async { - final playlists = await Pages( - spotify, - "v1/browse/categories/$category/playlists?country=${market.name}&locale=$locale", - (json) => json == null ? null : PlaylistSimple.fromJson(json), - 'playlists', - (json) => PlaylistsFeatured.fromJson(json), - ).getPage(5, pageParam); - - return playlists; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> genreSeeds(WidgetRef ref) { - final customSpotify = ref.watch(customSpotifyEndpointProvider); - final query = useQuery, dynamic>( - "genre-seeds", - customSpotify.listGenreSeeds, - ); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/lyrics.dart b/lib/services/queries/lyrics.dart deleted file mode 100644 index 618f960f..00000000 --- a/lib/services/queries/lyrics.dart +++ /dev/null @@ -1,114 +0,0 @@ -import 'dart:convert'; - -import 'package:collection/collection.dart'; -import 'package:fl_query/fl_query.dart'; -import 'package:fl_query_hooks/fl_query_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/map.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/service_utils.dart'; -import 'package:http/http.dart' as http; - -class LyricsQueries { - const LyricsQueries(); - - Query static( - Track? track, - String geniusAccessToken, - ) { - return useQuery( - "genius-lyrics-query/${track?.id}", - () async { - if (track == null) { - return "“Give this player a track to play”\n- S'Challa"; - } - final lyrics = await ServiceUtils.getLyrics( - track.name!, - track.artists?.map((s) => s.name).whereNotNull().toList() ?? [], - apiKey: geniusAccessToken, - optimizeQuery: true, - ); - - if (lyrics == null) throw Exception("Unable find lyrics"); - return lyrics; - }, - ); - } - - Query synced( - Track? track, - ) { - return useQuery( - "synced-lyrics/${track?.id}}", - () async { - if (track == null || track is! SourcedTrack) { - throw "No track currently"; - } - final timedLyrics = await ServiceUtils.getTimedLyrics(track); - if (timedLyrics == null) throw Exception("Unable to find lyrics"); - - return timedLyrics; - }, - ); - } - - /// The Concept behind this method was shamelessly stolen from - /// https://github.com/akashrchandran/spotify-lyrics-api - /// - /// Thanks to [akashrchandran](https://github.com/akashrchandran) for the idea - /// - /// Special thanks to [raptag](https://github.com/raptag) for discovering this - /// jem - - Query spotifySynced(WidgetRef ref, Track? track) { - return useSpotifyQuery( - "spotify-synced-lyrics/${track?.id}}", - (spotify) async { - if (track == null) { - throw "No track currently"; - } - final token = await spotify.getCredentials(); - final res = await http.get( - Uri.parse( - "https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token", - ), - headers: { - "User-Agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", - "App-platform": "WebPlayer", - "authorization": "Bearer ${token.accessToken}" - }); - - if (res.statusCode != 200) { - throw Exception("Unable to find lyrics"); - } - final linesRaw = Map.castFrom( - jsonDecode(res.body), - )["lyrics"]?["lines"] as List?; - - final lines = linesRaw?.map((line) { - return LyricSlice( - time: Duration(milliseconds: int.parse(line["startTimeMs"])), - text: line["words"] as String, - ); - }).toList() ?? - []; - - return SubtitleSimple( - lyrics: lines, - name: track.name!, - uri: res.request!.url, - rating: 100, - ); - }, - jsonConfig: JsonConfig( - fromJson: (json) => SubtitleSimple.fromJson(json.castKeyDeep()), - toJson: (data) => data.toJson(), - ), - ref: ref, - ); - } -} diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart deleted file mode 100644 index 836f9d72..00000000 --- a/lib/services/queries/playlist.dart +++ /dev/null @@ -1,318 +0,0 @@ -import 'package:catcher_2/catcher_2.dart'; -import 'package:fl_query/fl_query.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/components/library/playlist_generate/recommendation_attribute_dials.dart'; -import 'package:spotube/extensions/map.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -typedef RecommendationParameters = ({ - RecommendationAttribute acousticness, - RecommendationAttribute danceability, - RecommendationAttribute duration_ms, - RecommendationAttribute energy, - RecommendationAttribute instrumentalness, - RecommendationAttribute key, - RecommendationAttribute liveness, - RecommendationAttribute loudness, - RecommendationAttribute mode, - RecommendationAttribute popularity, - RecommendationAttribute speechiness, - RecommendationAttribute tempo, - RecommendationAttribute time_signature, - RecommendationAttribute valence, -}); - -Map recommendationAttributeToMap(RecommendationAttribute attr) => { - "min": attr.min, - "target": attr.target, - "max": attr.max, - }; - -({Map min, Map target, Map max}) - recommendationParametersToMap(RecommendationParameters params) { - final maxMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.max, - if (params.danceability != zeroValues) - "danceability": params.danceability.max, - if (params.duration_ms != zeroValues) "duration_ms": params.duration_ms.max, - if (params.energy != zeroValues) "energy": params.energy.max, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.max, - if (params.key != zeroValues) "key": params.key.max, - if (params.liveness != zeroValues) "liveness": params.liveness.max, - if (params.loudness != zeroValues) "loudness": params.loudness.max, - if (params.mode != zeroValues) "mode": params.mode.max, - if (params.popularity != zeroValues) "popularity": params.popularity.max, - if (params.speechiness != zeroValues) "speechiness": params.speechiness.max, - if (params.tempo != zeroValues) "tempo": params.tempo.max, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.max, - if (params.valence != zeroValues) "valence": params.valence.max, - }; - final minMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.min, - if (params.danceability != zeroValues) - "danceability": params.danceability.min, - if (params.duration_ms != zeroValues) "duration_ms": params.duration_ms.min, - if (params.energy != zeroValues) "energy": params.energy.min, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.min, - if (params.key != zeroValues) "key": params.key.min, - if (params.liveness != zeroValues) "liveness": params.liveness.min, - if (params.loudness != zeroValues) "loudness": params.loudness.min, - if (params.mode != zeroValues) "mode": params.mode.min, - if (params.popularity != zeroValues) "popularity": params.popularity.min, - if (params.speechiness != zeroValues) "speechiness": params.speechiness.min, - if (params.tempo != zeroValues) "tempo": params.tempo.min, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.min, - if (params.valence != zeroValues) "valence": params.valence.min, - }; - final targetMap = { - if (params.acousticness != zeroValues) - "acousticness": params.acousticness.target, - if (params.danceability != zeroValues) - "danceability": params.danceability.target, - if (params.duration_ms != zeroValues) - "duration_ms": params.duration_ms.target, - if (params.energy != zeroValues) "energy": params.energy.target, - if (params.instrumentalness != zeroValues) - "instrumentalness": params.instrumentalness.target, - if (params.key != zeroValues) "key": params.key.target, - if (params.liveness != zeroValues) "liveness": params.liveness.target, - if (params.loudness != zeroValues) "loudness": params.loudness.target, - if (params.mode != zeroValues) "mode": params.mode.target, - if (params.popularity != zeroValues) "popularity": params.popularity.target, - if (params.speechiness != zeroValues) - "speechiness": params.speechiness.target, - if (params.tempo != zeroValues) "tempo": params.tempo.target, - if (params.time_signature != zeroValues) - "time_signature": params.time_signature.target, - if (params.valence != zeroValues) "valence": params.valence.target, - }; - - return ( - max: maxMap, - min: minMap, - target: targetMap, - ); -} - -class PlaylistQueries { - const PlaylistQueries(); - - Query doesUserFollow( - WidgetRef ref, - String playlistId, - String userId, - ) { - return useSpotifyQuery( - "playlist-is-followed/$playlistId/$userId", - (spotify) async { - final result = - await spotify.playlists.followedByUsers(playlistId, [userId]); - return result[userId] ?? false; - }, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> ofMine(WidgetRef ref) { - return useSpotifyInfiniteQuery, dynamic, int>( - "current-user-playlists", - (page, spotify) async { - final playlists = await spotify.playlists.me.getPage(10, page * 10); - return playlists; - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) => - (lastPageData.items?.length ?? 0) < 10 || lastPageData.isLast - ? null - : lastPage + 1, - ref: ref, - ); - } - - Query, dynamic> ofMineAll(WidgetRef ref) { - return useSpotifyQuery, dynamic>( - "current-user-all-playlists", - (spotify) async { - var page = await spotify.playlists.me.getPage(50); - final playlists = []; - - if (page.isLast == true) { - return page.items?.toList() ?? []; - } - - playlists.addAll(page.items ?? []); - while (!page.isLast) { - page = await spotify.playlists.me.getPage(50, page.nextOffset); - playlists.addAll(page.items ?? []); - } - - return playlists; - }, - ref: ref, - ); - } - - Future> likedTracks(SpotifyApi spotify) async { - final tracks = await spotify.tracks.me.saved.all(); - - return tracks.map((e) => e.track!).toList(); - } - - Query, dynamic> likedTracksQuery(WidgetRef ref) { - final query = useCallback((spotify) => likedTracks(spotify), []); - final context = useContext(); - - return useSpotifyQuery, dynamic>( - "user-liked-tracks", - query, - jsonConfig: JsonConfig( - toJson: (tracks) => { - 'tracks': tracks.map((e) => e.toJson()).toList(), - }, - fromJson: (json) => (json['tracks'] as List) - .map( - (e) => Track.fromJson((e as Map).castKeyDeep()), - ) - .toList(), - ), - refreshConfig: RefreshConfig.withDefaults( - context, - // will never make it stale - staleDuration: const Duration(days: 60), - ), - ref: ref, - ); - } - - Query byId(WidgetRef ref, String id) { - return useSpotifyQuery( - "playlist/$id", - (spotify) async { - return await spotify.playlists.get(id); - }, - ref: ref, - ); - } - - Future> tracksOf( - int pageParam, - SpotifyApi spotify, - String playlistId, - ) async { - try { - final playlists = await spotify.playlists - .getTracksByPlaylistId(playlistId) - .getPage(20, pageParam * 20); - return playlists.items?.toList() ?? []; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - } - - int? tracksOfQueryNextPage(int lastPage, List lastPageData) { - if (lastPageData.length < 20) { - return null; - } - return lastPage + 1; - } - - InfiniteQuery, dynamic, int> tracksOfQuery( - WidgetRef ref, - String playlistId, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "playlist-tracks/$playlistId", - (page, spotify) => tracksOf(page, spotify, playlistId), - initialPage: 0, - nextPage: tracksOfQueryNextPage, - ref: ref, - ); - } - - InfiniteQuery, dynamic, int> featured( - WidgetRef ref, - ) { - return useSpotifyInfiniteQuery, dynamic, int>( - "featured-playlists", - (pageParam, spotify) async { - try { - final playlists = - await spotify.playlists.featured.getPage(5, pageParam); - return playlists; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - rethrow; - } - }, - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isLast || (lastPageData.items ?? []).length < 5) { - return null; - } - return lastPageData.nextOffset; - }, - ref: ref, - ); - } - - Query, dynamic> generate( - WidgetRef ref, { - ({List tracks, List artists, List genres})? seeds, - RecommendationParameters? parameters, - int limit = 20, - Market? market, - }) { - final marketOfPreference = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), - ); - final customSpotify = ref.watch(customSpotifyEndpointProvider); - - final parametersMap = - parameters == null ? null : recommendationParametersToMap(parameters); - - final query = useQuery, dynamic>( - "generate-playlist", - () async { - final tracks = await customSpotify.getRecommendations( - limit: limit, - market: market ?? marketOfPreference, - max: parametersMap?.max, - min: parametersMap?.min, - target: parametersMap?.target, - seedArtists: seeds?.artists, - seedGenres: seeds?.genres, - seedTracks: seeds?.tracks, - ); - return tracks; - }, - ); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/queries.dart b/lib/services/queries/queries.dart deleted file mode 100644 index 30c23268..00000000 --- a/lib/services/queries/queries.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:spotube/services/queries/album.dart'; -import 'package:spotube/services/queries/artist.dart'; -import 'package:spotube/services/queries/category.dart'; -import 'package:spotube/services/queries/lyrics.dart'; -import 'package:spotube/services/queries/playlist.dart'; -import 'package:spotube/services/queries/search.dart'; -import 'package:spotube/services/queries/tracks.dart'; -import 'package:spotube/services/queries/user.dart'; -import 'package:spotube/services/queries/views.dart'; - -class Queries { - const Queries._(); - final album = const AlbumQueries(); - final artist = const ArtistQueries(); - final category = const CategoryQueries(); - final lyrics = const LyricsQueries(); - final playlist = const PlaylistQueries(); - final search = const SearchQueries(); - final user = const UserQueries(); - final views = const ViewsQueries(); - final tracks = const TracksQueries(); -} - -const useQueries = Queries._(); diff --git a/lib/services/queries/search.dart b/lib/services/queries/search.dart deleted file mode 100644 index 3c6ee064..00000000 --- a/lib/services/queries/search.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:fl_query/fl_query.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/provider/spotify_provider.dart'; - -typedef SearchParams = ({ - SpotifyApi spotify, - SearchType searchType, - String query -}); - -class SearchQueries { - const SearchQueries(); - - static final queryJob = - InfiniteQueryJob.withVariableKey, dynamic, int, SearchParams>( - baseQueryKey: "search-query", - task: (variableKey, page, args) => args!.spotify.search.get( - args.query, - types: [args.searchType], - ).getPage(10, page), - initialPage: 0, - nextPage: (lastPage, lastPageData) { - if (lastPageData.isEmpty) return null; - if ((lastPageData.first.isLast || - (lastPageData.first.items ?? []).length < 10)) { - return null; - } - return lastPageData.first.nextOffset; - }, - enabled: false, - ); - - InfiniteQuery, dynamic, int> query( - WidgetRef ref, - String queryStr, - SearchType searchType, - ) { - final spotify = ref.watch(spotifyProvider); - final query = useInfiniteQueryJob, dynamic, int, SearchParams>( - job: queryJob(searchType.name), - args: (spotify: spotify, searchType: searchType, query: queryStr), - ); - - useEffect(() { - return ref.listenManual( - spotifyProvider, - (previous, next) { - if (previous != next) { - query.refreshAll(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/services/queries/tracks.dart b/lib/services/queries/tracks.dart deleted file mode 100644 index 52bab984..00000000 --- a/lib/services/queries/tracks.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; - -class TracksQueries { - const TracksQueries(); - - Query track(WidgetRef ref, String id) { - return useSpotifyQuery( - "track/$id", - (spotify) => spotify.tracks.get(id), - ref: ref, - ); - } -} diff --git a/lib/services/queries/user.dart b/lib/services/queries/user.dart deleted file mode 100644 index 82af600f..00000000 --- a/lib/services/queries/user.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:fl_query/fl_query.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/hooks/spotify/use_spotify_query.dart'; -import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - -class UserQueries { - const UserQueries(); - Query me(WidgetRef ref) { - final context = useContext(); - - return useSpotifyQuery( - "current-user", - (spotify) async { - final me = await spotify.me.get(); - if (ref.read(AuthenticationNotifier.provider) == null) return null; - if (me.images == null || me.images?.isEmpty == true) { - me.images = [ - Image() - ..height = 50 - ..width = 50 - ..url = TypeConversionUtils.image_X_UrlString( - me.images, - placeholder: ImagePlaceholder.artist, - ), - ]; - } - return me; - }, - refreshConfig: RefreshConfig.withDefaults( - context, - // will never make it stale - staleDuration: const Duration(days: 60), - ), - ref: ref, - ); - } - - Query friendActivity(WidgetRef ref) { - final customSpotify = ref.read(customSpotifyEndpointProvider); - return useSpotifyQuery( - "friend-activity", - (spotify) { - return customSpotify.getFriendActivity(); - }, - ref: ref, - ); - } -} diff --git a/lib/services/queries/views.dart b/lib/services/queries/views.dart deleted file mode 100644 index 4864ffe1..00000000 --- a/lib/services/queries/views.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:fl_query/fl_query.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:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class ViewsQueries { - const ViewsQueries(); - - Query?, dynamic> get( - WidgetRef ref, - String view, - ) { - final customSpotify = ref.watch(customSpotifyEndpointProvider); - final auth = ref.watch(AuthenticationNotifier.provider); - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); - - final locale = useContext().l10n.localeName; - - final query = useQuery?, dynamic>("views/$view", () { - if (auth == null) return null; - return customSpotify.getView( - view, - market: market, - country: market, - locale: locale, - ); - }); - - useEffect(() { - return ref.listenManual( - customSpotifyEndpointProvider, - (previous, next) { - if (previous != next) { - query.refresh(); - } - }, - ).close; - }, [query]); - - return query; - } -} diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/persisted_state_notifier.dart index 60f7b96e..9416a340 100644 --- a/lib/utils/persisted_state_notifier.dart +++ b/lib/utils/persisted_state_notifier.dart @@ -126,7 +126,7 @@ abstract class PersistedStateNotifier extends StateNotifier { } } - Map castNestedJson(Map map) { + static Map castNestedJson(Map map) { return Map.castFrom( map.map((key, value) { if (value is Map) { diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index cd594a2a..d5eb68f6 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -61,6 +61,9 @@ abstract class TypeConversionUtils { .entries .map( (artist) => Builder(builder: (context) { + if (artist.value.name == null) { + return Text("Spotify", style: textStyle); + } return AnchorButton( (artist.key != artists.length - 1) ? "${artist.value.name}, " diff --git a/pubspec.lock b/pubspec.lock index 4485b118..bbf4faeb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -675,30 +675,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - fl_query: - dependency: "direct main" - description: - name: fl_query - sha256: daee5ab0ed8899baa201b89b5813107df5258144a9e2bcf192dbcf922c57d985 - url: "https://pub.dev" - source: hosted - version: "1.0.0" - fl_query_devtools: - dependency: "direct main" - description: - name: fl_query_devtools - sha256: "2ae8905fd4a95f1d245a1b54057c31c8d27fc961223bcb7ce13088bcf6595059" - url: "https://pub.dev" - source: hosted - version: "0.1.0" - fl_query_hooks: - dependency: "direct main" - description: - name: fl_query_hooks - sha256: "6c88b3bfbdc3e1330931b927903929d7351f86fc63266ac93b3acb9f133a09a9" - url: "https://pub.dev" - source: hosted - version: "1.0.0" fluentui_system_icons: dependency: "direct main" description: @@ -1319,14 +1295,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.7.1" - json_view: - dependency: transitive - description: - name: json_view - sha256: "905c69f9e69d1eab5406b87ab6c10c3706c04c70c6a4959621bd2b43c2d27374" - url: "https://pub.dev" - source: hosted - version: "0.4.2" leak_tracker: dependency: transitive description: @@ -1495,14 +1463,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - mutex: - dependency: transitive - description: - name: mutex - sha256: "03116a4e46282a671b46c12de649d72c0ed18188ffe12a8d0fc63e83f4ad88f4" - url: "https://pub.dev" - source: hosted - version: "3.0.1" nested: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e055c9d7..ef8401bc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,9 +32,6 @@ dependencies: duration: ^3.0.12 envied: ^0.3.0 file_selector: ^1.0.1 - fl_query: ^1.0.0 - fl_query_hooks: ^1.0.0 - fl_query_devtools: ^0.1.0 fluentui_system_icons: ^1.1.189 flutter: sdk: flutter From 7545ff64159b3e5561abfe887014d8cee872597b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 21 Mar 2024 00:18:43 +0600 Subject: [PATCH 019/261] refactor: use extension method for image to url string --- lib/components/album/album_card.dart | 4 +-- lib/components/artist/artist_card.dart | 4 +-- .../playlist_generate/simple_track_tile.dart | 4 +-- .../library/user_downloads/download_item.dart | 4 +-- lib/components/player/player.dart | 4 +-- lib/components/playlist/playlist_card.dart | 4 +-- .../playlist/playlist_create_dialog.dart | 4 +-- lib/components/root/bottom_player.dart | 4 +-- lib/components/root/sidebar.dart | 4 +-- .../dialogs/playlist_add_track_dialog.dart | 4 +-- .../shared/track_tile/track_options.dart | 5 ++-- .../shared/track_tile/track_tile.dart | 4 +-- lib/extensions/image.dart | 28 +++++++++++++++++++ .../configurators/use_get_storage_perms.dart | 4 +-- lib/pages/album/album.dart | 4 +-- lib/pages/artist/section/footer.dart | 4 +-- lib/pages/artist/section/header.dart | 4 +-- .../playlist_generate/playlist_generate.dart | 10 +++---- lib/pages/lyrics/lyrics.dart | 4 +-- lib/pages/playlist/playlist.dart | 4 +-- lib/pages/track/track.dart | 7 ++--- lib/provider/download_manager_provider.dart | 7 +++-- .../proxy_playlist_provider.dart | 4 +-- .../audio_services/audio_services.dart | 10 ++++--- .../audio_services/linux_audio_service.dart | 4 +-- .../audio_services/windows_audio_service.dart | 21 ++++++++------ lib/utils/type_conversion_utils.dart | 25 ----------------- 27 files changed, 99 insertions(+), 90 deletions(-) create mode 100644 lib/extensions/image.dart diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 3838b7a4..97db9d72 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -49,8 +50,7 @@ class AlbumCard extends HookConsumerWidget { } return PlaybuttonCard( - imageUrl: TypeConversionUtils.image_X_UrlString( - album.images, + imageUrl: album.images.asUrlString( placeholder: ImagePlaceholder.collection, ), margin: const EdgeInsets.symmetric(horizontal: 10), diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index ac3e9bec..322ad501 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -6,6 +6,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -20,8 +21,7 @@ class ArtistCard extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final backgroundImage = UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - artist.images, + artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ); diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/components/library/playlist_generate/simple_track_tile.dart index e592969e..08d5060f 100644 --- a/lib/components/library/playlist_generate/simple_track_tile.dart +++ b/lib/components/library/playlist_generate/simple_track_tile.dart @@ -4,6 +4,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; class SimpleTrackTile extends HookWidget { @@ -21,8 +22,7 @@ class SimpleTrackTile extends HookWidget { leading: ClipRRect( borderRadius: BorderRadius.circular(8), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), height: 40, diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index 1cb5e559..16bf1afe 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -5,6 +5,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -51,8 +52,7 @@ class DownloadItem extends HookConsumerWidget { child: UniversalImage( height: 40, width: 40, - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 5d5a39af..6fcbbd1c 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -18,6 +18,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/models/local_track.dart'; @@ -59,8 +60,7 @@ class PlayerView extends HookConsumerWidget { }, [mediaQuery.lgAndUp]); String albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - currentTrack?.album?.images, + () => (currentTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), [currentTrack?.album?.images], diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index ffbfbae9..83e25a85 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -43,8 +44,7 @@ class PlaylistCard extends HookConsumerWidget { margin: const EdgeInsets.symmetric(horizontal: 10), title: playlist.name!, description: playlist.description, - imageUrl: TypeConversionUtils.image_X_UrlString( - playlist.images, + imageUrl: playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), isPlaying: isPlaylistPlaying, diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index 669dce51..cae51444 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -14,6 +14,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -163,8 +164,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { children: [ UniversalImage( path: field.value?.path ?? - TypeConversionUtils.image_X_UrlString( - updatingPlaylist?.images, + (updatingPlaylist?.images).asUrlString( placeholder: ImagePlaceholder.collection, ), height: 200, diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 3f70490a..e6cf17dc 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; @@ -39,8 +40,7 @@ class BottomPlayer extends HookConsumerWidget { String albumArt = useMemoized( () => playlist.activeTrack?.album?.images?.isNotEmpty == true - ? TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, + ? (playlist.activeTrack?.album?.images).asUrlString( index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, placeholder: ImagePlaceholder.albumArt, ) diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 21259a94..9049ecf1 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -11,6 +11,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/provider/download_manager_provider.dart'; @@ -244,8 +245,7 @@ class SidebarFooter extends HookConsumerWidget { final me = ref.watch(meProvider); final data = me.asData?.value; - final avatarImg = TypeConversionUtils.image_X_UrlString( - data?.images, + final avatarImg = (data?.images).asUrlString( index: (data?.images?.length ?? 1) - 1, placeholder: ImagePlaceholder.artist, ); diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index 1f1807da..28044b41 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart @@ -7,6 +7,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -105,8 +106,7 @@ class PlaylistAddTrackDialog extends HookConsumerWidget { return CheckboxListTile( secondary: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - playlist.images, + playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), ), diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 8522738d..590a5889 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -17,6 +17,7 @@ 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/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -294,8 +295,8 @@ class TrackOptions extends HookConsumerWidget { child: ClipRRect( borderRadius: BorderRadius.circular(10), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString(track.album!.images, - placeholder: ImagePlaceholder.albumArt), + path: track.album!.images + .asUrlString(placeholder: ImagePlaceholder.albumArt), fit: BoxFit.cover, ), ), diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index ecadc1c6..afdc19a4 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -13,6 +13,7 @@ import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -135,8 +136,7 @@ class TrackTile extends HookConsumerWidget { child: AspectRatio( aspectRatio: 1, child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, + path: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), fit: BoxFit.cover, diff --git a/lib/extensions/image.dart b/lib/extensions/image.dart new file mode 100644 index 00000000..f84bd37a --- /dev/null +++ b/lib/extensions/image.dart @@ -0,0 +1,28 @@ +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; +import 'package:collection/collection.dart'; + +extension SpotifyImageExtensions on List? { + String asUrlString({ + int index = 1, + required ImagePlaceholder placeholder, + }) { + final String placeholderUrl = { + ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, + ImagePlaceholder.artist: Assets.userPlaceholder.path, + ImagePlaceholder.collection: Assets.placeholder.path, + ImagePlaceholder.online: + "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", + }[placeholder]!; + + final sortedImage = this?.sorted((a, b) => a.width!.compareTo(b.width!)); + + return sortedImage != null && sortedImage.isNotEmpty + ? sortedImage[ + index > sortedImage.length - 1 ? sortedImage.length - 1 : index] + .url! + : placeholderUrl; + } +} diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart index 3fcb369b..86b495c4 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -25,11 +25,11 @@ void useGetStoragePermissions(WidgetRef ref) { if (hasNoStoragePerm) { await Permission.storage.request(); - if (isMounted()) ref.refresh(localTracksProvider); + if (isMounted()) ref.invalidate(localTracksProvider); } if (hasNoAudioPerm) { await Permission.audio.request(); - if (isMounted()) ref.refresh(localTracksProvider); + if (isMounted()) ref.invalidate(localTracksProvider); } }, null, diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index fac0a6a6..0f36756f 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -4,6 +4,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -23,8 +24,7 @@ class AlbumPage extends HookConsumerWidget { return InheritedTrackView( collectionId: album.id!, - image: TypeConversionUtils.image_X_UrlString( - album.images, + image: album.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), title: album.name!, diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index ac166252..c53f2c54 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -5,6 +5,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -18,8 +19,7 @@ class ArtistPageFooter extends ConsumerWidget { final ThemeData(:textTheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final artistImage = TypeConversionUtils.image_X_UrlString( - artist.images, + final artistImage = artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ); final summary = ref.watch(artistWikipediaSummaryProvider(artist)); diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 7756da15..dcf3114e 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -8,6 +8,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -44,8 +45,7 @@ class ArtistPageHeader extends HookConsumerWidget { BlacklistedElement.artist(artistId, artist.name!), ); - final image = TypeConversionUtils.image_X_UrlString( - artist.images, + final image = artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ); diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 642ceb6c..81fbbfe3 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -15,6 +15,7 @@ import 'package:spotube/components/shared/image/universal_image.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/extensions/image.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; @@ -84,8 +85,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { autocompleteOptionBuilder: (option, onSelected) => ListTile( leading: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - option.images, + option.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), @@ -117,8 +117,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { selectedSeedBuilder: (artist) => Chip( avatar: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - artist.images, + artist.images.asUrlString( placeholder: ImagePlaceholder.artist, ), ), @@ -163,8 +162,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { autocompleteOptionBuilder: (option, onSelected) => ListTile( leading: CircleAvatar( backgroundImage: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - option.album?.images, + (option.album?.images).asUrlString( placeholder: ImagePlaceholder.artist, ), ), diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 9c777660..0482cfe9 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -11,6 +11,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/themed_button_tab_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; @@ -28,8 +29,7 @@ class LyricsPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlist = ref.watch(ProxyPlaylistNotifier.provider); String albumArt = useMemoized( - () => TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, + () => (playlist.activeTrack?.album?.images).asUrlString( index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, placeholder: ImagePlaceholder.albumArt, ), diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 7962c66a..3a0f9ec6 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -6,6 +6,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_ import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -30,8 +31,7 @@ class PlaylistPage extends HookConsumerWidget { return InheritedTrackView( collectionId: playlist.id!, - image: TypeConversionUtils.image_X_UrlString( - playlist.images, + image: playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), pagination: PaginationProps( diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index ca5dbf95..aef9a083 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -12,6 +12,7 @@ import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -60,8 +61,7 @@ class TrackPage extends HookConsumerWidget { decoration: BoxDecoration( image: DecorationImage( image: UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - track.album!.images, + track.album!.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), @@ -104,8 +104,7 @@ class TrackPage extends HookConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(10), child: UniversalImage( - path: TypeConversionUtils.image_X_UrlString( - track.album!.images, + path: track.album!.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), height: 200, diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index dc538938..abf4ed6c 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -52,8 +53,10 @@ class DownloadManagerProvider extends ChangeNotifier { } final imageBytes = await downloadImage( - TypeConversionUtils.image_X_UrlString(track.album?.images, - placeholder: ImagePlaceholder.albumArt, index: 1), + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + index: 1, + ), ); final metadata = Metadata( diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 0811fe35..aea873dd 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -10,6 +10,7 @@ import 'package:http/http.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; @@ -522,8 +523,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final palette = await PaletteGenerator.fromImageProvider( UniversalImage.imageProvider( - TypeConversionUtils.image_X_UrlString( - state.activeTrack?.album?.images, + (state.activeTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), height: 50, diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index a6ecac3f..068b41ba 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -2,6 +2,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; @@ -50,10 +51,11 @@ class AudioServices { duration: track is SourcedTrack ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), - artUri: Uri.parse(TypeConversionUtils.image_X_UrlString( - track.album?.images ?? [], - placeholder: ImagePlaceholder.albumArt, - )), + artUri: Uri.parse( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + ), playable: true, )); } diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart index 2dfef362..11399e67 100644 --- a/lib/services/audio_services/linux_audio_service.dart +++ b/lib/services/audio_services/linux_audio_service.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:dbus/dbus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -309,8 +310,7 @@ class _MprisMediaPlayer2Player extends DBusObject { (await audioPlayer.duration)?.inMicroseconds ?? 0, ), "mpris:artUrl": DBusString( - TypeConversionUtils.image_X_UrlString( - playlist.activeTrack?.album?.images, + (playlist.activeTrack?.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index fde88145..2df0e9fe 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:smtc_windows/smtc_windows.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; @@ -80,16 +81,18 @@ class WindowsAudioService { if (!smtc.enabled) { await smtc.enableSmtc(); } - await smtc.updateMetadata(MusicMetadata( - title: track.name!, - albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", - artist: TypeConversionUtils.artists_X_String(track.artists ?? []), - album: track.album?.name ?? "Unknown", - thumbnail: TypeConversionUtils.image_X_UrlString( - track.album?.images ?? [], - placeholder: ImagePlaceholder.albumArt, + await smtc.updateMetadata( + MusicMetadata( + title: track.name!, + albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", + artist: + TypeConversionUtils.artists_X_String(track.artists ?? []), + album: track.album?.name ?? "Unknown", + thumbnail: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), ), - )); + ); } void dispose() { diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index d5eb68f6..639299a1 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -2,14 +2,11 @@ import 'dart:io'; -import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart' hide Image; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; -import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/links/anchor_button.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/service_utils.dart'; enum ImagePlaceholder { @@ -20,28 +17,6 @@ enum ImagePlaceholder { } abstract class TypeConversionUtils { - static String image_X_UrlString( - List? images, { - int index = 1, - required ImagePlaceholder placeholder, - }) { - final String placeholderUrl = { - ImagePlaceholder.albumArt: Assets.albumPlaceholder.path, - ImagePlaceholder.artist: Assets.userPlaceholder.path, - ImagePlaceholder.collection: Assets.placeholder.path, - ImagePlaceholder.online: - "https://avatars.dicebear.com/api/bottts/${PrimitiveUtils.uuid.v4()}.png", - }[placeholder]!; - - final sortedImage = images?.sorted((a, b) => a.width!.compareTo(b.width!)); - - return sortedImage != null && sortedImage.isNotEmpty - ? sortedImage[ - index > sortedImage.length - 1 ? sortedImage.length - 1 : index] - .url! - : placeholderUrl; - } - static String artists_X_String(List artists) { return artists.map((e) => e.name?.replaceAll(",", " ")).join(", "); } From 1cea95bbda96e706d097e05e15a9a0c9d9552567 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 21 Mar 2024 00:30:27 +0600 Subject: [PATCH 020/261] refactor: artist name string as extension --- lib/components/album/album_card.dart | 3 ++- lib/components/library/user_local_tracks.dart | 3 ++- lib/components/player/player.dart | 6 ++---- lib/components/player/player_actions.dart | 9 +++------ lib/components/player/player_queue.dart | 4 ++-- lib/components/player/player_track_details.dart | 5 ++--- lib/components/player/sibling_tracks_sheet.dart | 3 ++- lib/components/shared/track_tile/track_tile.dart | 5 ++--- lib/extensions/artist_simple.dart | 6 ++++++ lib/pages/lyrics/plain_lyrics.dart | 4 ++-- lib/pages/lyrics/synced_lyrics.dart | 6 ++---- lib/provider/discord_provider.dart | 5 ++--- lib/provider/download_manager_provider.dart | 3 ++- lib/provider/scrobbler_provider.dart | 5 +++-- lib/services/audio_services/audio_services.dart | 2 +- lib/services/audio_services/windows_audio_service.dart | 4 ++-- lib/utils/type_conversion_utils.dart | 4 ---- 17 files changed, 37 insertions(+), 40 deletions(-) diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 97db9d72..1696fc48 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -59,7 +60,7 @@ class AlbumCard extends HookConsumerWidget { updating.value, title: album.name!, description: - "${album.albumType?.formatted} • ${TypeConversionUtils.artists_X_String(album.artists ?? [])}", + "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", onTap: () { ServiceUtils.push(context, "/album/${album.id}", extra: album); }, diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index b8f647a5..f9b53330 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -21,6 +21,7 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -242,7 +243,7 @@ class UserLocalTracks extends HookConsumerWidget { return sortedTracks .map((e) => ( weightedRatio( - "${e.name} - ${TypeConversionUtils.artists_X_String(e.artists ?? [])}", + "${e.name} - ${e.artists?.asString() ?? ""}", searchController.text, ), e, diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 6fcbbd1c..04dd90df 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -16,6 +16,7 @@ import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; @@ -239,10 +240,7 @@ class PlayerView extends HookConsumerWidget { ), if (isLocalTrack) Text( - TypeConversionUtils.artists_X_String< - Artist>( - currentTrack?.artists ?? [], - ), + currentTrack?.artists?.asString() ?? "", style: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, color: bodyTextColor, diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 18168af1..4102e2ba 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -3,11 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/heart_button.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; @@ -16,7 +16,6 @@ import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/sleep_timer_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerActions extends HookConsumerWidget { final MainAxisAlignment mainAxisAlignment; @@ -56,10 +55,8 @@ class PlayerActions extends HookConsumerWidget { (element) => element.name == playlist.activeTrack?.name && element.album?.name == playlist.activeTrack?.album?.name && - TypeConversionUtils.artists_X_String( - element.artists ?? []) == - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), + element.artists?.asString() == + playlist.activeTrack?.artists?.asString(), ) == true; }, [localTracks, playlist.activeTrack]); diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 449b6c2e..141479a6 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -12,11 +12,11 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerQueue extends HookConsumerWidget { final bool floating; @@ -55,7 +55,7 @@ class PlayerQueue extends HookConsumerWidget { return tracks .map((e) => ( weightedRatio( - '${e.name!} - ${TypeConversionUtils.artists_X_String(e.artists!)}', + '${e.name!} - ${e.artists?.asString() ?? ""}', searchText.value, ), e diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index fd97fd74..268f4b76 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -5,6 +5,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/link_text.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -54,9 +55,7 @@ class PlayerTrackDetails extends HookConsumerWidget { ), ), Text( - TypeConversionUtils.artists_X_String( - playback.activeTrack?.artists ?? [], - ), + playback.activeTrack?.artists?.asString() ?? "", overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall!.copyWith(color: color), ) diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index c805cb42..a0684075 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -10,6 +10,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; @@ -67,7 +68,7 @@ class SiblingTracksSheet extends HookConsumerWidget { ).trim(); final defaultSearchTerm = - "$title - ${TypeConversionUtils.artists_X_String(playlist.activeTrack?.artists ?? [])}"; + "$title - ${playlist.activeTrack?.artists?.asString() ?? ""}"; final searchController = useTextEditingController( text: defaultSearchTerm, ); diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index afdc19a4..77caaaef 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -11,6 +11,7 @@ import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; @@ -230,9 +231,7 @@ class TrackTile extends HookConsumerWidget { alignment: Alignment.centerLeft, child: track is LocalTrack ? Text( - TypeConversionUtils.artists_X_String( - track.artists ?? [], - ), + track.artists?.asString() ?? '', ) : ClipRect( child: ConstrainedBox( diff --git a/lib/extensions/artist_simple.dart b/lib/extensions/artist_simple.dart index caf2e510..6a80300e 100644 --- a/lib/extensions/artist_simple.dart +++ b/lib/extensions/artist_simple.dart @@ -11,3 +11,9 @@ extension ArtistJson on ArtistSimple { }; } } + +extension ArtistExtension on List { + String asString() { + return map((e) => e.name?.replaceAll(",", " ")).join(", "); + } +} diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 96ad8d41..7513ad96 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -8,6 +8,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; @@ -55,8 +56,7 @@ class PlainLyrics extends HookConsumerWidget { ), Center( child: Text( - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), + playlist.activeTrack?.artists?.asString() ?? "", style: (mediaQuery.mdAndUp ? textTheme.headlineSmall : textTheme.titleLarge) diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 872ad514..5e7a24c8 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -3,10 +3,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; @@ -16,7 +16,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:stroke_text/stroke_text.dart'; class SyncedLyrics extends HookConsumerWidget { @@ -84,8 +83,7 @@ class SyncedLyrics extends HookConsumerWidget { if (isModal != true) Center( child: Text( - TypeConversionUtils.artists_X_String( - playlist.activeTrack?.artists ?? []), + playlist.activeTrack?.artists?.asString() ?? "", style: mediaQuery.mdAndUp ? textTheme.headlineSmall : textTheme.titleLarge, diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index 3aa547a9..e07e2d3b 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -4,9 +4,9 @@ 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/extensions/artist_simple.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; @@ -23,8 +23,7 @@ class Discord extends ChangeNotifier { void updatePresence(Track track) { clear(); - final artistNames = - TypeConversionUtils.artists_X_String(track.artists ?? []); + final artistNames = track.artists?.asString() ?? ""; discordRPC?.updatePresence( DiscordPresence( details: "Song: ${track.name} by $artistNames", diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index abf4ed6c..32c5c98c 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -9,6 +9,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/download_manager/download_manager.dart'; @@ -137,7 +138,7 @@ class DownloadManagerProvider extends ChangeNotifier { String getTrackFileUrl(Track track) { final name = - "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.${downloadCodec.name}"; + "${track.name} - ${track.artists?.asString() ?? ""}.${downloadCodec.name}"; return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); } diff --git a/lib/provider/scrobbler_provider.dart b/lib/provider/scrobbler_provider.dart index bf234e62..0c204664 100644 --- a/lib/provider/scrobbler_provider.dart +++ b/lib/provider/scrobbler_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:scrobblenaut/scrobblenaut.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/env.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -85,14 +86,14 @@ class ScrobblerNotifier extends PersistedStateNotifier { Future love(Track track) async { await state?.scrobblenaut.track.love( - artist: TypeConversionUtils.artists_X_String(track.artists!), + artist: track.artists!.asString(), track: track.name!, ); } Future unlove(Track track) async { await state?.scrobblenaut.track.unLove( - artist: TypeConversionUtils.artists_X_String(track.artists!), + artist: track.artists!.asString(), track: track.name!, ); } diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 068b41ba..4fa0ae1d 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -47,7 +47,7 @@ class AudioServices { id: track.id!, album: track.album?.name ?? "", title: track.name!, - artist: TypeConversionUtils.artists_X_String(track.artists ?? []), + artist: track.artists?.toString() ?? "", duration: track is SourcedTrack ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index 2df0e9fe..8a58ecd8 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:smtc_windows/smtc_windows.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -85,8 +86,7 @@ class WindowsAudioService { MusicMetadata( title: track.name!, albumArtist: track.artists?.firstOrNull?.name ?? "Unknown", - artist: - TypeConversionUtils.artists_X_String(track.artists ?? []), + artist: track.artists?.asString() ?? "Unknown", album: track.album?.name ?? "Unknown", thumbnail: (track.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index 639299a1..668123eb 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -17,10 +17,6 @@ enum ImagePlaceholder { } abstract class TypeConversionUtils { - static String artists_X_String(List artists) { - return artists.map((e) => e.name?.replaceAll(",", " ")).join(", "); - } - static Widget artists_X_ClickableArtists( List artists, { WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center, From 1a6cea926f7267424f153be2b7d5f49dbc6b355c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 21 Mar 2024 00:38:10 +0600 Subject: [PATCH 021/261] refactor: use widget for artist link instead of a utility function --- .../library/user_downloads/download_item.dart | 5 +- lib/components/player/player.dart | 7 +-- .../player/player_track_details.dart | 5 +- .../shared/dialogs/track_details_dialog.dart | 6 +- lib/components/shared/links/artist_link.dart | 57 +++++++++++++++++++ .../shared/track_tile/track_options.dart | 5 +- .../shared/track_tile/track_tile.dart | 5 +- lib/pages/track/track.dart | 6 +- lib/utils/type_conversion_utils.dart | 44 -------------- 9 files changed, 75 insertions(+), 65 deletions(-) create mode 100644 lib/components/shared/links/artist_link.dart diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index 16bf1afe..aed567ab 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/download_manager_provider.dart'; @@ -59,8 +60,8 @@ class DownloadItem extends HookConsumerWidget { ), ), title: Text(track.name ?? ''), - subtitle: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], + subtitle: ArtistLink( + artists: track.artists ?? [], mainAxisAlignment: WrapAlignment.start, ), trailing: isQueryingSourceInfo diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 04dd90df..941bc3eb 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/player/player_actions.dart'; @@ -13,6 +12,7 @@ import 'package:spotube/components/player/player_queue.dart'; import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/components/shared/animated_gradient.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; @@ -247,9 +247,8 @@ class PlayerView extends HookConsumerWidget { ), ) else - TypeConversionUtils - .artists_X_ClickableArtists( - currentTrack?.artists ?? [], + ArtistLink( + artists: currentTrack?.artists ?? [], textStyle: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index 268f4b76..bbf8c995 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -4,6 +4,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; @@ -74,8 +75,8 @@ class PlayerTrackDetails extends HookConsumerWidget { overflow: TextOverflow.ellipsis, style: TextStyle(fontWeight: FontWeight.bold, color: color), ), - TypeConversionUtils.artists_X_ClickableArtists( - playback.activeTrack?.artists ?? [], + ArtistLink( + artists: playback.activeTrack?.artists ?? [], onRouteChange: (route) { ServiceUtils.push(context, route); }, diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/shared/dialogs/track_details_dialog.dart index 4e65b8e5..da2a140b 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/shared/dialogs/track_details_dialog.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/extensions/duration.dart'; class TrackDetailsDialog extends HookWidget { @@ -24,8 +24,8 @@ class TrackDetailsDialog extends HookWidget { final detailsMap = { context.l10n.title: track.name!, - context.l10n.artist: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], + context.l10n.artist: ArtistLink( + artists: track.artists ?? [], mainAxisAlignment: WrapAlignment.start, textStyle: const TextStyle(color: Colors.blue), ), diff --git a/lib/components/shared/links/artist_link.dart b/lib/components/shared/links/artist_link.dart new file mode 100644 index 00000000..af8b186a --- /dev/null +++ b/lib/components/shared/links/artist_link.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ArtistLink extends StatelessWidget { + final List artists; + final WrapCrossAlignment crossAxisAlignment; + final WrapAlignment mainAxisAlignment; + final TextStyle textStyle; + final void Function(String route)? onRouteChange; + + const ArtistLink({ + super.key, + required this.artists, + this.crossAxisAlignment = WrapCrossAlignment.center, + this.mainAxisAlignment = WrapAlignment.center, + this.textStyle = const TextStyle(), + this.onRouteChange, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + crossAxisAlignment: crossAxisAlignment, + alignment: mainAxisAlignment, + children: artists + .asMap() + .entries + .map( + (artist) => Builder(builder: (context) { + if (artist.value.name == null) { + return Text("Spotify", style: textStyle); + } + return AnchorButton( + (artist.key != artists.length - 1) + ? "${artist.value.name}, " + : artist.value.name!, + onTap: () { + if (onRouteChange != null) { + onRouteChange?.call("/artist/${artist.value.id}"); + } else { + ServiceUtils.push( + context, + "/artist/${artist.value.id}", + ); + } + }, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + }), + ) + .toList(), + ); + } +} diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 590a5889..1288783e 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -15,6 +15,7 @@ import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; @@ -309,9 +310,7 @@ class TrackOptions extends HookConsumerWidget { ), subtitle: Align( alignment: Alignment.centerLeft, - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists!, - ), + child: ArtistLink(artists: track.artists!), ), ), ], diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 77caaaef..930d922c 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -9,6 +9,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; import 'package:spotube/extensions/artist_simple.dart'; @@ -236,9 +237,7 @@ class TrackTile extends HookConsumerWidget { : ClipRect( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 40), - child: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], - ), + child: ArtistLink(artists: track.artists ?? []), ), ), ), diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index aef9a083..cbb75ed8 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -8,6 +8,7 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart'; @@ -145,10 +146,7 @@ class TrackPage extends HookConsumerWidget { children: [ const Icon(SpotubeIcons.artist), const Gap(5), - TypeConversionUtils - .artists_X_ClickableArtists( - track.artists!, - ), + ArtistLink(artists: track.artists!), ], ), const Gap(10), diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index 668123eb..18d42040 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -2,12 +2,9 @@ import 'dart:io'; -import 'package:flutter/widgets.dart' hide Image; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/utils/service_utils.dart'; enum ImagePlaceholder { albumArt, @@ -17,47 +14,6 @@ enum ImagePlaceholder { } abstract class TypeConversionUtils { - static Widget artists_X_ClickableArtists( - List artists, { - WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center, - WrapAlignment mainAxisAlignment = WrapAlignment.center, - TextStyle textStyle = const TextStyle(), - void Function(String route)? onRouteChange, - }) { - return Wrap( - crossAxisAlignment: crossAxisAlignment, - alignment: mainAxisAlignment, - children: artists - .asMap() - .entries - .map( - (artist) => Builder(builder: (context) { - if (artist.value.name == null) { - return Text("Spotify", style: textStyle); - } - return AnchorButton( - (artist.key != artists.length - 1) - ? "${artist.value.name}, " - : artist.value.name!, - onTap: () { - if (onRouteChange != null) { - onRouteChange("/artist/${artist.value.id}"); - } else { - ServiceUtils.push( - context, - "/artist/${artist.value.id}", - ); - } - }, - overflow: TextOverflow.ellipsis, - style: textStyle, - ); - }), - ) - .toList(), - ); - } - static Album simpleAlbum_X_Album(AlbumSimple albumSimple) { Album album = Album(); album.albumType = albumSimple.albumType; From 9f96b5c537ef485e2ce15318bd00357b910c7928 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 21 Mar 2024 00:48:21 +0600 Subject: [PATCH 022/261] refactor: use extension methods for simple album to album and simple track to track conversion --- lib/components/album/album_card.dart | 7 +- lib/components/artist/artist_card.dart | 1 - .../playlist_generate/simple_track_tile.dart | 1 - lib/components/library/user_albums.dart | 8 +- .../library/user_downloads/download_item.dart | 1 - lib/components/library/user_local_tracks.dart | 4 +- lib/components/player/player.dart | 2 +- .../player/player_track_details.dart | 1 - .../player/sibling_tracks_sheet.dart | 1 - lib/components/playlist/playlist_card.dart | 1 - .../playlist/playlist_create_dialog.dart | 1 - lib/components/root/bottom_player.dart | 1 - lib/components/root/sidebar.dart | 1 - .../dialogs/playlist_add_track_dialog.dart | 1 - .../shared/track_tile/track_options.dart | 2 +- .../shared/track_tile/track_tile.dart | 1 - lib/extensions/album_simple.dart | 20 ++++- lib/extensions/image.dart | 8 +- lib/extensions/track.dart | 64 ++++++++++++- lib/models/local_track.dart | 2 +- lib/pages/album/album.dart | 1 - lib/pages/artist/section/footer.dart | 2 +- lib/pages/artist/section/header.dart | 1 - .../playlist_generate/playlist_generate.dart | 1 - lib/pages/lyrics/lyrics.dart | 1 - lib/pages/lyrics/plain_lyrics.dart | 2 - lib/pages/playlist/playlist.dart | 1 - lib/pages/search/sections/albums.dart | 4 +- lib/pages/track/track.dart | 2 +- lib/provider/download_manager_provider.dart | 1 - .../proxy_playlist_provider.dart | 1 - lib/provider/scrobbler_provider.dart | 1 - lib/provider/spotify/album/releases.dart | 5 +- lib/provider/spotify/album/tracks.dart | 5 +- lib/provider/spotify/spotify.dart | 3 +- .../audio_services/audio_services.dart | 1 - .../audio_services/linux_audio_service.dart | 1 - .../audio_services/windows_audio_service.dart | 1 - lib/utils/type_conversion_utils.dart | 89 ------------------- 39 files changed, 105 insertions(+), 146 deletions(-) delete mode 100644 lib/utils/type_conversion_utils.dart diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 1696fc48..083c1949 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -6,11 +6,11 @@ import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; extension FormattedAlbumType on AlbumType { String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); @@ -41,10 +41,7 @@ class AlbumCard extends HookConsumerWidget { Future> fetchAllTrack() async { if (album.tracks != null && album.tracks!.isNotEmpty) { - return album.tracks! - .map((track) => - TypeConversionUtils.simpleTrack_X_Track(track, album)) - .toList(); + return album.tracks!.map((track) => track.asTrack(album)).toList(); } await ref.read(albumTracksProvider(album).future); return ref.read(albumTracksProvider(album).notifier).fetchAll(); diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 322ad501..ebe18e72 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -11,7 +11,6 @@ import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistCard extends HookConsumerWidget { final Artist artist; diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/components/library/playlist_generate/simple_track_tile.dart index 08d5060f..cf4ddb1a 100644 --- a/lib/components/library/playlist_generate/simple_track_tile.dart +++ b/lib/components/library/playlist_generate/simple_track_tile.dart @@ -5,7 +5,6 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class SimpleTrackTile extends HookWidget { final Track track; diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart index 07ba7a40..be421a40 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -12,12 +12,11 @@ import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - class UserAlbums extends HookConsumerWidget { const UserAlbums({super.key}); @@ -99,10 +98,7 @@ class UserAlbums extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [NotFound()], ), - for (final album in albums) - AlbumCard( - TypeConversionUtils.simpleAlbum_X_Album(album), - ), + for (final album in albums) AlbumCard(album.toAlbum()), if (albums.isNotEmpty && albumsQuery.asData?.value.hasMore == true) Skeletonizer( diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart index aed567ab..a145fdad 100644 --- a/lib/components/library/user_downloads/download_item.dart +++ b/lib/components/library/user_downloads/download_item.dart @@ -10,7 +10,6 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadItem extends HookConsumerWidget { final Track track; diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index f9b53330..5450bc34 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -23,11 +23,11 @@ import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; const supportedAudioTypes = [ @@ -112,7 +112,7 @@ final localTracksProvider = FutureProvider>((ref) async { final tracks = filesWithMetadata .map( (fileWithMetadata) => LocalTrack.fromTrack( - track: TypeConversionUtils.localTrack_X_Track( + track: Track().fromFile( fileWithMetadata["file"], metadata: fileWithMetadata["metadata"], art: fileWithMetadata["art"], diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 941bc3eb..5559be73 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -27,7 +27,7 @@ import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:url_launcher/url_launcher_string.dart'; class PlayerView extends HookConsumerWidget { diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index bbf8c995..95fecdc2 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -10,7 +10,6 @@ import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { final String? albumArt; diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index a0684075..99ab223f 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -25,7 +25,6 @@ import 'package:spotube/services/sourced_track/sources/jiosaavn.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; final sourceInfoToIconMap = { YoutubeSourceInfo: const Icon(SpotubeIcons.youtube, color: Color(0xFFFF0000)), diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 83e25a85..8915e97a 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -8,7 +8,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index cae51444..bac98b64 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -17,7 +17,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCreateDialog extends HookConsumerWidget { /// Track ids to add to the playlist diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index e6cf17dc..16633f7c 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -23,7 +23,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class BottomPlayer extends HookConsumerWidget { BottomPlayer({super.key}); diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 9049ecf1..903e812e 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -21,7 +21,6 @@ import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class Sidebar extends HookConsumerWidget { final int? selectedIndex; diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index 28044b41..5d493a68 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart @@ -9,7 +9,6 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistAddTrackDialog extends HookConsumerWidget { /// The id of the playlist this dialog was opened from diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 1288783e..76c91003 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -26,7 +26,7 @@ import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:url_launcher/url_launcher_string.dart'; enum TrackOptionValue { diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 930d922c..897abdae 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -19,7 +19,6 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class TrackTile extends HookConsumerWidget { /// [index] will not be shown if null diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart index 00db4dca..7c8ae09e 100644 --- a/lib/extensions/album_simple.dart +++ b/lib/extensions/album_simple.dart @@ -1,6 +1,6 @@ import 'package:spotify/spotify.dart'; -extension AlbumJson on AlbumSimple { +extension AlbumExtensions on AlbumSimple { Map toJson() { return { "albumType": albumType?.name, @@ -15,4 +15,22 @@ extension AlbumJson on AlbumSimple { .toList(), }; } + + Album toAlbum() { + Album album = Album(); + album.albumType = albumType; + album.artists = artists; + album.availableMarkets = availableMarkets; + album.externalUrls = externalUrls; + album.href = href; + album.id = id; + album.images = images; + album.name = name; + album.releaseDate = releaseDate; + album.releaseDatePrecision = releaseDatePrecision; + album.tracks = tracks; + album.type = type; + album.uri = uri; + return album; + } } diff --git a/lib/extensions/image.dart b/lib/extensions/image.dart index f84bd37a..ee78653a 100644 --- a/lib/extensions/image.dart +++ b/lib/extensions/image.dart @@ -1,9 +1,15 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:collection/collection.dart'; +enum ImagePlaceholder { + albumArt, + artist, + collection, + online, +} + extension SpotifyImageExtensions on List? { String asUrlString({ int index = 1, diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 51498b33..d8258a6d 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -1,10 +1,46 @@ +import 'dart:io'; + +import 'package:metadata_god/metadata_god.dart'; +import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/artist_simple.dart'; -extension TrackJson on Track { +extension TrackExtensions on Track { + Track fromFile( + File file, { + Metadata? metadata, + String? art, + }) { + album = Album() + ..name = metadata?.album ?? "Unknown" + ..images = [if (art != null) Image()..url = art] + ..genres = [if (metadata?.genre != null) metadata!.genre!] + ..artists = [ + Artist() + ..name = metadata?.albumArtist ?? "Unknown" + ..id = metadata?.albumArtist ?? "Unknown" + ..type = "artist", + ] + ..id = metadata?.album + ..releaseDate = metadata?.year?.toString(); + artists = [ + Artist() + ..name = metadata?.artist ?? "Unknown" + ..id = metadata?.artist ?? "Unknown" + ]; + + id = metadata?.title ?? basenameWithoutExtension(file.path); + name = metadata?.title ?? basenameWithoutExtension(file.path); + type = "track"; + uri = file.path; + durationMs = (metadata?.durationMs?.toInt() ?? 0); + + return this; + } + Map toJson() { - return TrackJson.trackToJson(this); + return TrackExtensions.trackToJson(this); } static Map trackToJson(Track track) { @@ -30,3 +66,27 @@ extension TrackJson on Track { }; } } + +extension TrackSimpleExtensions on TrackSimple { + Track asTrack(AlbumSimple album) { + Track track = Track(); + track.name = name; + track.album = album; + track.artists = artists; + track.availableMarkets = availableMarkets; + track.discNumber = discNumber; + track.durationMs = durationMs; + track.explicit = explicit; + track.externalUrls = externalUrls; + track.href = href; + track.id = id; + track.isPlayable = isPlayable; + track.linkedFrom = linkedFrom; + track.name = name; + track.previewUrl = previewUrl; + track.trackNumber = trackNumber; + track.type = type; + track.uri = uri; + return track; + } +} diff --git a/lib/models/local_track.dart b/lib/models/local_track.dart index 134cd327..923f5f26 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -37,7 +37,7 @@ class LocalTrack extends Track { Map toJson() { return { - ...TrackJson.trackToJson(this), + ...TrackExtensions.trackToJson(this), 'path': path, }; } diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 0f36756f..7c03b6dd 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -6,7 +6,6 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class AlbumPage extends HookConsumerWidget { final AlbumSimple album; diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index c53f2c54..835dbdd3 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -7,7 +7,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:url_launcher/url_launcher_string.dart'; class ArtistPageFooter extends ConsumerWidget { diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index dcf3114e..1f1d028d 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -14,7 +14,6 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class ArtistPageHeader extends HookConsumerWidget { final String artistId; diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 81fbbfe3..49a33164 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -20,7 +20,6 @@ import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 0482cfe9..6d406e33 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -19,7 +19,6 @@ import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class LyricsPage extends HookConsumerWidget { final bool isModal; diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 7513ad96..f1c6ec2e 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -15,8 +15,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; - class PlainLyrics extends HookConsumerWidget { final PaletteColor palette; final bool? isModal; diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 3a0f9ec6..ce070b06 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -8,7 +8,6 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistPage extends HookConsumerWidget { final PlaylistSimple playlist; diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index 6d0f1508..dee27041 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -4,9 +4,9 @@ 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/album_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class SearchAlbumsSection extends HookConsumerWidget { const SearchAlbumsSection({ @@ -21,7 +21,7 @@ class SearchAlbumsSection extends HookConsumerWidget { () => query.asData?.value.items .cast() - .map(TypeConversionUtils.simpleAlbum_X_Album) + .map((e) => e.toAlbum()) .toList() ?? [], [query.value], diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index cbb75ed8..829256d4 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -17,7 +17,7 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:spotube/extensions/constrains.dart'; class TrackPage extends HookConsumerWidget { diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 32c5c98c..c964f982 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -16,7 +16,6 @@ import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class DownloadManagerProvider extends ChangeNotifier { DownloadManagerProvider({required this.ref}) diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index aea873dd..aa63e3f3 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -33,7 +33,6 @@ 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'; /// Things implemented: /// * [x] Sponsor-Block skip diff --git a/lib/provider/scrobbler_provider.dart b/lib/provider/scrobbler_provider.dart index 0c204664..9ad2a58b 100644 --- a/lib/provider/scrobbler_provider.dart +++ b/lib/provider/scrobbler_provider.dart @@ -8,7 +8,6 @@ import 'package:spotube/collections/env.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class ScrobblerState { final String username; diff --git a/lib/provider/spotify/album/releases.dart b/lib/provider/spotify/album/releases.dart index 471df707..cacddbdf 100644 --- a/lib/provider/spotify/album/releases.dart +++ b/lib/provider/spotify/album/releases.dart @@ -36,10 +36,7 @@ class AlbumReleasesNotifier .newReleases(country: market) .getPage(limit, offset); - return albums.items - ?.map(TypeConversionUtils.simpleAlbum_X_Album) - .toList() ?? - []; + return albums.items?.map((album) => album.toAlbum()).toList() ?? []; } @override diff --git a/lib/provider/spotify/album/tracks.dart b/lib/provider/spotify/album/tracks.dart index 9556cc52..e9f712e7 100644 --- a/lib/provider/spotify/album/tracks.dart +++ b/lib/provider/spotify/album/tracks.dart @@ -31,10 +31,7 @@ class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier TypeConversionUtils.simpleTrack_X_Track(e, arg)) - .toList() ?? - []; + return tracks.items?.map((e) => e.asTrack(arg)).toList() ?? []; } @override diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index ea28b6d8..b152db65 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -12,6 +12,7 @@ import 'package:spotify/spotify.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; // ignore: depend_on_referenced_packages, implementation_imports import 'package:riverpod/src/async_notifier.dart'; +import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/map.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/lyrics.dart'; @@ -23,7 +24,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/services/wikipedia/wikipedia.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:http/http.dart' as http; -import 'package:spotube/utils/type_conversion_utils.dart'; + import 'package:wikipedia_api/wikipedia_api.dart'; part 'album/favorite.dart'; diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 4fa0ae1d..facbcc4c 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -7,7 +7,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class AudioServices { final MobileAudioService? mobile; diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart index 11399e67..84a6f7b8 100644 --- a/lib/services/audio_services/linux_audio_service.dart +++ b/lib/services/audio_services/linux_audio_service.dart @@ -9,7 +9,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; final dbus = DBusClient.session(); diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index 8a58ecd8..a3ee31e1 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -8,7 +8,6 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class WindowsAudioService { final SMTCWindows smtc; diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart deleted file mode 100644 index 18d42040..00000000 --- a/lib/utils/type_conversion_utils.dart +++ /dev/null @@ -1,89 +0,0 @@ -// ignore_for_file: non_constant_identifier_names - -import 'dart:io'; - -import 'package:metadata_god/metadata_god.dart'; -import 'package:path/path.dart'; -import 'package:spotify/spotify.dart'; - -enum ImagePlaceholder { - albumArt, - artist, - collection, - online, -} - -abstract class TypeConversionUtils { - static Album simpleAlbum_X_Album(AlbumSimple albumSimple) { - Album album = Album(); - album.albumType = albumSimple.albumType; - album.artists = albumSimple.artists; - album.availableMarkets = albumSimple.availableMarkets; - album.externalUrls = albumSimple.externalUrls; - album.href = albumSimple.href; - album.id = albumSimple.id; - album.images = albumSimple.images; - album.name = albumSimple.name; - album.releaseDate = albumSimple.releaseDate; - album.releaseDatePrecision = albumSimple.releaseDatePrecision; - album.tracks = albumSimple.tracks; - album.type = albumSimple.type; - album.uri = albumSimple.uri; - return album; - } - - static Track simpleTrack_X_Track(TrackSimple trackSmp, AlbumSimple album) { - Track track = Track(); - track.name = trackSmp.name; - track.album = album; - track.artists = trackSmp.artists; - track.availableMarkets = trackSmp.availableMarkets; - track.discNumber = trackSmp.discNumber; - track.durationMs = trackSmp.durationMs; - track.explicit = trackSmp.explicit; - track.externalUrls = trackSmp.externalUrls; - track.href = trackSmp.href; - track.id = trackSmp.id; - track.isPlayable = trackSmp.isPlayable; - track.linkedFrom = trackSmp.linkedFrom; - track.name = trackSmp.name; - track.previewUrl = trackSmp.previewUrl; - track.trackNumber = trackSmp.trackNumber; - track.type = trackSmp.type; - track.uri = trackSmp.uri; - return track; - } - - static Track localTrack_X_Track( - File file, { - Metadata? metadata, - String? art, - }) { - final track = Track(); - track.album = Album() - ..name = metadata?.album ?? "Unknown" - ..images = [if (art != null) Image()..url = art] - ..genres = [if (metadata?.genre != null) metadata!.genre!] - ..artists = [ - Artist() - ..name = metadata?.albumArtist ?? "Unknown" - ..id = metadata?.albumArtist ?? "Unknown" - ..type = "artist", - ] - ..id = metadata?.album - ..releaseDate = metadata?.year?.toString(); - track.artists = [ - Artist() - ..name = metadata?.artist ?? "Unknown" - ..id = metadata?.artist ?? "Unknown" - ]; - - track.id = metadata?.title ?? basenameWithoutExtension(file.path); - track.name = metadata?.title ?? basenameWithoutExtension(file.path); - track.type = "track"; - track.uri = file.path; - track.durationMs = (metadata?.durationMs?.toInt() ?? 0); - - return track; - } -} From e99f32b6101c24d8177c57dfa5543e5763ba3ec7 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 23 Mar 2024 19:00:37 +0600 Subject: [PATCH 023/261] chore: set yt as jiosaavn fallback --- lib/services/sourced_track/sourced_track.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index c73f3078..c06efd87 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -127,7 +127,7 @@ abstract class SourcedTrack extends Track { weakMatch: true, ), AudioSource.jiosaavn => - await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref), + await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref), }; } on HttpClientClosedException catch (_) { return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); From 82b1cfa0d775e3958c666280943a893c9113d468 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 23 Mar 2024 20:19:34 +0600 Subject: [PATCH 024/261] feat: search history support #1236 --- lib/collections/spotube_icons.dart | 1 + lib/pages/search/search.dart | 96 +++++++++++++++++++++++++---- lib/services/kv_store/kv_store.dart | 6 ++ 3 files changed, 91 insertions(+), 12 deletions(-) diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 6cf92085..98c8ad45 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -115,4 +115,5 @@ abstract class SpotubeIcons { static const github = SimpleIcons.github; static const openCollective = SimpleIcons.opencollective; static const anonymous = FeatherIcons.user; + static const history = FeatherIcons.clock; } diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index e666c9aa..ca66e02a 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,15 +14,16 @@ import 'package:spotube/components/shared/fallbacks/anonymous_fallback.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/hooks/utils/use_force_update.dart'; import 'package:spotube/pages/search/sections/albums.dart'; import 'package:spotube/pages/search/sections/artists.dart'; import 'package:spotube/pages/search/sections/playlists.dart'; import 'package:spotube/pages/search/sections/tracks.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:collection/collection.dart'; class SearchPage extends HookConsumerWidget { const SearchPage({super.key}); @@ -29,7 +32,7 @@ class SearchPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final searchTerm = ref.watch(searchTermStateProvider); - final controller = useTextEditingController(text: searchTerm); + final controller = useSearchController(); ref.watch(AuthenticationNotifier.provider); final authenticationNotifier = @@ -45,6 +48,12 @@ class SearchPage extends HookConsumerWidget { final isFetching = queries.every((s) => s.isLoading); + useEffect(() { + controller.text = searchTerm; + + return null; + }, []); + final resultWidget = HookBuilder( builder: (context) { final controller = useScrollController(); @@ -88,24 +97,87 @@ class SearchPage extends HookConsumerWidget { vertical: 10, ), color: theme.scaffoldBackgroundColor, - child: TextField( - controller: controller, - autofocus: - queries.none((s) => s.value != null && !s.hasError) && - !kIsMobile, - decoration: InputDecoration( - prefixIcon: const Icon(SpotubeIcons.search), - hintText: "${context.l10n.search}...", - ), - onSubmitted: (value) async { + child: SearchAnchor( + searchController: controller, + viewBuilder: (_) => HookBuilder(builder: (context) { + final searchController = useListenable(controller); + final update = useForceUpdate(); + final suggestions = searchController.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + searchController.text.toLowerCase(), + ) > + 50, + ) + .toList(); + + return ListView.builder( + itemCount: suggestions.length, + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + + return ListTile( + leading: const Icon(SpotubeIcons.history), + title: Text(suggestion), + trailing: IconButton( + icon: const Icon(SpotubeIcons.trash), + onPressed: () { + KVStoreService.setRecentSearches( + KVStoreService.recentSearches + .where((s) => s != suggestion) + .toList(), + ); + update(); + }, + ), + onTap: () { + controller.closeView(suggestion); + ref + .read(searchTermStateProvider.notifier) + .state = suggestion; + }, + ); + }, + ); + }), + suggestionsBuilder: (context, controller) { + return []; + }, + viewOnSubmitted: (value) async { + controller.closeView(value); Timer( const Duration(milliseconds: 50), () { ref.read(searchTermStateProvider.notifier).state = value; + if (value.trim().isEmpty) { + return; + } + KVStoreService.setRecentSearches( + { + value, + ...KVStoreService.recentSearches, + }.toList(), + ); }, ); }, + builder: (context, controller) { + return SearchBar( + autoFocus: queries.none( + (s) => s.value != null && !s.hasError) && + !kIsMobile, + controller: controller, + leading: const Icon(SpotubeIcons.search), + hintText: "${context.l10n.search}...", + onTap: controller.openView, + onChanged: (_) => controller.openView(), + ); + }, ), ), Expanded( diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index 5845b120..f94ec4ee 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -17,4 +17,10 @@ abstract class KVStoreService { sharedPreferences.getBool('askedForBatteryOptimization') ?? false; static Future setAskedForBatteryOptimization(bool value) async => await sharedPreferences.setBool('askedForBatteryOptimization', value); + + static List get recentSearches => + sharedPreferences.getStringList('recentSearches') ?? []; + + static Future setRecentSearches(List value) async => + await sharedPreferences.setStringList('recentSearches', value); } From ee97aedcfc64d972e70dc51aa25232913ecb4073 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 23 Mar 2024 20:37:52 +0600 Subject: [PATCH 025/261] chore: remove direct access to .value without calling asData.value --- .vscode/settings.json | 2 ++ lib/components/home/sections/genres.dart | 6 ++--- .../home/sections/made_for_user.dart | 4 ++-- lib/components/library/user_albums.dart | 6 ++--- lib/components/library/user_local_tracks.dart | 6 ++--- lib/components/shared/heart_button.dart | 4 ++-- .../shared/track_tile/track_options.dart | 2 +- .../sections/body/use_is_user_playlist.dart | 4 ++-- lib/pages/album/album.dart | 6 ++--- lib/pages/artist/artist.dart | 7 +++--- lib/pages/artist/section/footer.dart | 10 ++++----- lib/pages/artist/section/header.dart | 2 +- lib/pages/artist/section/top_tracks.dart | 6 ++--- lib/pages/home/genres/genres.dart | 4 ++-- .../playlist_generate/playlist_generate.dart | 2 +- .../playlist_generate_result.dart | 22 ++++++++++--------- lib/pages/playlist/liked_playlist.dart | 2 +- lib/pages/playlist/playlist.dart | 6 ++--- lib/pages/search/search.dart | 4 ++-- lib/pages/search/sections/albums.dart | 2 +- 20 files changed, 56 insertions(+), 51 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 472520ab..0fedc544 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,10 +4,12 @@ "acousticness", "Buildless", "danceability", + "fuzzywuzzy", "instrumentalness", "Mpris", "riverpod", "Scrobblenaut", + "skeletonizer", "speechiness", "Spotube", "winget" diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index 87f28821..ac2644f0 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -26,12 +26,12 @@ class HomeGenresSection extends HookConsumerWidget { final categoriesQuery = ref.watch(categoriesProvider); final categories = useMemoized( () => - categoriesQuery.value - ?.where((c) => (c.icons?.length ?? 0) > 0) + categoriesQuery.asData?.value + .where((c) => (c.icons?.length ?? 0) > 0) .take(mediaQuery.mdAndDown ? 6 : 10) .toList() ?? [], - [mediaQuery.mdAndDown, categoriesQuery.value], + [mediaQuery.mdAndDown, categoriesQuery.asData?.value], ); return SliverMainAxisGroup( diff --git a/lib/components/home/sections/made_for_user.dart b/lib/components/home/sections/made_for_user.dart index 439d9c38..d1d269f6 100644 --- a/lib/components/home/sections/made_for_user.dart +++ b/lib/components/home/sections/made_for_user.dart @@ -12,9 +12,9 @@ class HomeMadeForUserSection extends HookConsumerWidget { final madeForUser = ref.watch(viewProvider("made-for-x-hub")); return SliverList.builder( - itemCount: madeForUser.value?["content"]?["items"]?.length ?? 0, + itemCount: madeForUser.asData?.value["content"]?["items"]?.length ?? 0, itemBuilder: (context, index) { - final item = madeForUser.value?["content"]?["items"]?[index]; + final item = madeForUser.asData?.value["content"]?["items"]?[index]; final playlists = item["content"]?["items"] ?.where((itemL2) => itemL2["type"] == "playlist") .map((itemL2) => PlaylistSimple.fromJson(itemL2)) diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart index be421a40..f58d6693 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -44,7 +44,7 @@ class UserAlbums extends HookConsumerWidget { .map((e) => e.$2) .toList() ?? []; - }, [albumsQuery.value, searchText.value]); + }, [albumsQuery.asData?.value, searchText.value]); if (auth == null) { return const AnonymousFallback(); @@ -87,8 +87,8 @@ class UserAlbums extends HookConsumerWidget { runAlignment: WrapAlignment.center, crossAxisAlignment: WrapCrossAlignment.center, children: [ - if (albumsQuery.value == null || - albumsQuery.value!.items.isEmpty) + if (albumsQuery.asData?.value == null || + albumsQuery.asData!.value.items.isEmpty) ...List.generate( 10, (index) => AlbumCard(FakeData.album), diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 5450bc34..e2098570 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -160,7 +160,7 @@ class UserLocalTracks extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final trackSnapshot = ref.watch(localTracksProvider); final isPlaylistPlaying = - playlist.containsTracks(trackSnapshot.value ?? []); + playlist.containsTracks(trackSnapshot.asData?.value ?? []); final searchController = useTextEditingController(); useValueListenable(searchController); @@ -177,13 +177,13 @@ class UserLocalTracks extends HookConsumerWidget { children: [ const SizedBox(width: 10), FilledButton( - onPressed: trackSnapshot.value != null + onPressed: trackSnapshot.asData?.value != null ? () async { if (trackSnapshot.asData?.value.isNotEmpty == true) { if (!isPlaylistPlaying) { await playLocalTracks( ref, - trackSnapshot.value!, + trackSnapshot.asData!.value, ); } else { // TODO: Remove stop capability diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart index a733c36c..9475f9e3 100644 --- a/lib/components/shared/heart_button.dart +++ b/lib/components/shared/heart_button.dart @@ -68,7 +68,7 @@ UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { () => savedTracks.asData?.value.any((element) => element.id == track.id) ?? false, - [savedTracks.value, track.id], + [savedTracks.asData?.value, track.id], ); final scrobblerNotifier = ref.read(scrobblerProvider.notifier); @@ -109,7 +109,7 @@ class TrackHeartButton extends HookConsumerWidget { ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, isLiked: isLiked, - onPressed: savedTracks.value != null + onPressed: savedTracks.asData?.value != null ? () { toggleTrackLike(track); } diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 76c91003..29349602 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -348,7 +348,7 @@ class TrackOptions extends HookConsumerWidget { leading: const Icon(SpotubeIcons.queueRemove), title: Text(context.l10n.remove_from_queue), ), - if (me.value != null) + if (me.asData?.value != null) PopSheetEntry( value: TrackOptionValue.favorite, leading: favorites.isLiked diff --git a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart index d32efed2..2f87ccc8 100644 --- a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart +++ b/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart @@ -10,9 +10,9 @@ bool useIsUserPlaylist(WidgetRef ref, String playlistId) { () => userPlaylistsQuery.asData?.value.items.any((e) => e.id == playlistId && - me.value != null && + me.asData?.value != null && e.owner?.id == me.asData?.value.id) ?? false, - [userPlaylistsQuery.value, playlistId, me.value], + [userPlaylistsQuery.asData?.value, playlistId, me.asData?.value], ); } diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index 7c03b6dd..b24b69f4 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -45,11 +45,11 @@ class AlbumPage extends HookConsumerWidget { ), routePath: "/album/${album.id}", shareUrl: album.externalUrls!.spotify!, - isLiked: isSavedAlbum.value ?? false, - onHeart: isSavedAlbum.value == null + isLiked: isSavedAlbum.asData?.value ?? false, + onHeart: isSavedAlbum.asData?.value == null ? null : () async { - if (isSavedAlbum.value!) { + if (isSavedAlbum.asData!.value) { await favoriteAlbumsNotifier.removeFavorites([album.id!]); } else { await favoriteAlbumsNotifier.addFavorites([album.id!]); diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index c153f0af..c3b04691 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -35,7 +35,7 @@ class ArtistPage extends HookConsumerWidget { ), extendBodyBehindAppBar: true, body: Builder(builder: (context) { - if (artistQuery.hasError && artistQuery.value == null) { + if (artistQuery.hasError && artistQuery.asData?.value == null) { return Center(child: Text(artistQuery.error.toString())); } return Skeletonizer( @@ -66,11 +66,12 @@ class ArtistPage extends HookConsumerWidget { SliverSafeArea( sliver: ArtistPageRelatedArtists(artistId: artistId), ), - if (artistQuery.value != null) + if (artistQuery.asData?.value != null) SliverSafeArea( top: false, sliver: SliverToBoxAdapter( - child: ArtistPageFooter(artist: artistQuery.value!), + child: + ArtistPageFooter(artist: artistQuery.asData!.value), ), ), ], diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index 835dbdd3..4707b939 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -23,7 +23,7 @@ class ArtistPageFooter extends ConsumerWidget { placeholder: ImagePlaceholder.artist, ); final summary = ref.watch(artistWikipediaSummaryProvider(artist)); - if (summary.value == null) return const SizedBox.shrink(); + if (summary.asData?.value == null) return const SizedBox.shrink(); return Container( margin: const EdgeInsets.all(16), @@ -39,9 +39,9 @@ class ArtistPageFooter extends ConsumerWidget { BlendMode.darken, ), image: UniversalImage.imageProvider( - summary.value!.thumbnail?.source_ ?? artistImage, - height: summary.value!.thumbnail?.height.toDouble(), - width: summary.value!.thumbnail?.width.toDouble(), + summary.asData?.value!.thumbnail?.source_ ?? artistImage, + height: summary.asData?.value!.thumbnail?.height.toDouble(), + width: summary.asData?.value!.thumbnail?.width.toDouble(), ), fit: BoxFit.cover, alignment: Alignment.center, @@ -70,7 +70,7 @@ class ArtistPageFooter extends ConsumerWidget { ), const TextSpan(text: '\n\n'), TextSpan( - text: summary.value!.extract, + text: summary.asData?.value!.extract, ), TextSpan( text: '\n...read more at wikipedia', diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 1f1d028d..e5cb8900 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -22,7 +22,7 @@ class ArtistPageHeader extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final artistQuery = ref.watch(artistProvider(artistId)); - final artist = artistQuery.value ?? FakeData.artist; + final artist = artistQuery.asData?.value ?? FakeData.artist; final scaffoldMessenger = ScaffoldMessenger.of(context); final mediaQuery = MediaQuery.of(context); diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 9ad2b0db..173ace54 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -23,7 +23,7 @@ class ArtistPageTopTracks extends HookConsumerWidget { final topTracksQuery = ref.watch(artistTopTracksProvider(artistId)); final isPlaylistPlaying = playlist.containsTracks( - topTracksQuery.value ?? [], + topTracksQuery.asData?.value ?? [], ); if (topTracksQuery.hasError) { @@ -34,8 +34,8 @@ class ArtistPageTopTracks extends HookConsumerWidget { ); } - final topTracks = - topTracksQuery.value ?? List.generate(10, (index) => FakeData.track); + final topTracks = topTracksQuery.asData?.value ?? + List.generate(10, (index) => FakeData.track); void playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index ed6c2835..a981cbe7 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -39,9 +39,9 @@ class GenrePage extends HookConsumerWidget { crossAxisSpacing: 12, mainAxisSpacing: 12, ), - itemCount: categories.value!.length, + itemCount: categories.asData!.value.length, itemBuilder: (context, index) { - final category = categories.value![index]; + final category = categories.asData!.value[index]; final gradient = gradients[Random().nextInt(gradients.length)]; return InkWell( borderRadius: BorderRadius.circular(8), diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 49a33164..5044090d 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -187,7 +187,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ); final genreSelector = MultiSelectField( - options: genresCollection.value ?? [], + options: genresCollection.asData?.value ?? [], selectedOptions: genres.value, getValueForOption: (option) => option, onSelected: (value) { diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index deb86a97..5390c337 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -34,12 +34,12 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ); useEffect(() { - if (generatedPlaylist.value != null) { + if (generatedPlaylist.asData?.value != null) { selectedTracks.value = - generatedPlaylist.value!.map((e) => e.id!).toList(); + generatedPlaylist.asData!.value.map((e) => e.id!).toList(); } return null; - }, [generatedPlaylist.value]); + }, [generatedPlaylist.asData?.value]); final isAllTrackSelected = selectedTracks.value.length == (generatedPlaylist.asData?.value.length ?? 0); @@ -78,7 +78,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ? null : () async { await playlistNotifier.load( - generatedPlaylist.value!.where( + generatedPlaylist.asData!.value.where( (e) => selectedTracks.value.contains(e.id!), ), autoPlay: true, @@ -92,7 +92,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ? null : () async { await playlistNotifier.addTracks( - generatedPlaylist.value!.where( + generatedPlaylist.asData!.value.where( (e) => selectedTracks.value.contains(e.id!), ), ); @@ -142,7 +142,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { openFromPlaylist: null, tracks: selectedTracks.value .map( - (e) => generatedPlaylist.value! + (e) => generatedPlaylist.asData!.value .firstWhere( (element) => element.id == e, ), @@ -167,7 +167,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ], ), const SizedBox(height: 16), - if (generatedPlaylist.value != null) + if (generatedPlaylist.asData?.value != null) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -181,8 +181,9 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { if (isAllTrackSelected) { selectedTracks.value = []; } else { - selectedTracks.value = generatedPlaylist.value - ?.map((e) => e.id!) + selectedTracks.value = generatedPlaylist + .asData?.value + .map((e) => e.id!) .toList() ?? []; } @@ -203,7 +204,8 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - for (final track in generatedPlaylist.value ?? []) + for (final track + in generatedPlaylist.asData?.value ?? []) CheckboxListTile( value: selectedTracks.value.contains(track.id), onChanged: (value) { diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index eeea8cb1..72983518 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -15,7 +15,7 @@ class LikedPlaylistPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final likedTracks = ref.watch(likedTracksProvider); - final tracks = likedTracks.value ?? []; + final tracks = likedTracks.asData?.value ?? []; return InheritedTrackView( collectionId: playlist.id!, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index ce070b06..d9d224e0 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -48,9 +48,9 @@ class PlaylistPage extends HookConsumerWidget { description: playlist.description, tracks: tracks.asData?.value.items ?? [], routePath: '/playlist/${playlist.id}', - isLiked: isFavoritePlaylist.value ?? false, + isLiked: isFavoritePlaylist.asData?.value ?? false, shareUrl: playlist.externalUrls?.spotify ?? "", - onHeart: isFavoritePlaylist.value == null + onHeart: isFavoritePlaylist.asData?.value == null ? null : () async { final confirmed = isUserPlaylist @@ -62,7 +62,7 @@ class PlaylistPage extends HookConsumerWidget { : true; if (!confirmed) return null; - if (isFavoritePlaylist.value!) { + if (isFavoritePlaylist.asData!.value) { await favoritePlaylistsNotifier.removeFavorite(playlist); } else { await favoritePlaylistsNotifier.addFavorite(playlist); diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index ca66e02a..c58b8df3 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -168,8 +168,8 @@ class SearchPage extends HookConsumerWidget { }, builder: (context, controller) { return SearchBar( - autoFocus: queries.none( - (s) => s.value != null && !s.hasError) && + autoFocus: queries.none((s) => + s.asData?.value != null && !s.hasError) && !kIsMobile, controller: controller, leading: const Icon(SpotubeIcons.search), diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index dee27041..d15c34ff 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -24,7 +24,7 @@ class SearchAlbumsSection extends HookConsumerWidget { .map((e) => e.toAlbum()) .toList() ?? [], - [query.value], + [query.asData?.value], ); return HorizontalPlaybuttonCardView( From 044d3b4820437cdab01a905f6569dcdbeee0b8f8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 28 Mar 2024 22:49:40 +0600 Subject: [PATCH 026/261] refactor: use CustomScrollView in player queue --- lib/components/player/player_queue.dart | 315 ++++++++++++------------ 1 file changed, 158 insertions(+), 157 deletions(-) diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 141479a6..7641fad5 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -3,11 +3,15 @@ import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; @@ -109,171 +113,168 @@ class PlayerQueue extends HookConsumerWidget { searchText.value = ''; } }, - child: Column( - children: [ - if (!floating) - Container( - height: 5, - width: 100, - margin: const EdgeInsets.only(bottom: 5, top: 2), - decoration: BoxDecoration( - color: headlineColor, - borderRadius: BorderRadius.circular(20), - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (mediaQuery.mdAndUp || !isSearching.value) ...[ - const SizedBox(width: 10), - Text( - context.l10n.tracks_in_queue(tracks.length), - style: TextStyle( - color: headlineColor, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - const Spacer(), - ], - if (mediaQuery.mdAndUp || isSearching.value) - TextField( - onChanged: (value) { - searchText.value = value; - }, - decoration: InputDecoration( - hintText: context.l10n.search, - isDense: true, - prefixIcon: mediaQuery.smAndDown - ? IconButton( - icon: const Icon( - Icons.arrow_back_ios_new_outlined, - ), - onPressed: () { - isSearching.value = false; - searchText.value = ''; - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size.square(20), - ), - ) - : const Icon(SpotubeIcons.filter), - constraints: BoxConstraints( - maxHeight: 40, - maxWidth: mediaQuery.smAndDown - ? mediaQuery.size.width - 40 - : 300, + child: InterScrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + slivers: [ + if (!floating) + SliverToBoxAdapter( + child: Center( + child: Container( + height: 5, + width: 100, + margin: const EdgeInsets.only(bottom: 5, top: 2), + decoration: BoxDecoration( + color: headlineColor, + borderRadius: BorderRadius.circular(20), ), ), - ) - else - IconButton.filledTonal( - icon: const Icon(SpotubeIcons.filter), - onPressed: () { - isSearching.value = !isSearching.value; - }, ), - if (mediaQuery.mdAndUp || !isSearching.value) ...[ - const SizedBox(width: 10), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: - theme.scaffoldBackgroundColor.withOpacity(0.5), - foregroundColor: theme.textTheme.headlineSmall?.color, - ), - child: Row( - children: [ - const Icon(SpotubeIcons.playlistRemove), - const SizedBox(width: 5), - Text(context.l10n.clear_all), - ], - ), - onPressed: () { - playlistNotifier.stop(); - Navigator.of(context).pop(); - }, + ), + SliverAppBar( + floating: true, + pinned: false, + snap: false, + backgroundColor: Colors.transparent, + elevation: 0, + automaticallyImplyLeading: !isSearching.value, + title: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, ), - const SizedBox(width: 10), - ], - ], - ), - const SizedBox(height: 10), - if (!isSearching.value && searchText.value.isEmpty) - Flexible( - child: ReorderableListView.builder( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, - scrollController: controller, - itemCount: tracks.length, - shrinkWrap: true, - buildDefaultDragHandles: false, - onReorderStart: (index) { - HapticFeedback.selectionClick(); - }, - onReorderEnd: (index) { - HapticFeedback.selectionClick(); - }, - itemBuilder: (context, i) { - final track = tracks.elementAt(i); - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - leadingActions: [ - ReorderableDragStartListener( - index: i, - child: const Icon(SpotubeIcons.dragHandle), + child: SizedBox( + height: kToolbarHeight, + child: mediaQuery.mdAndUp || !isSearching.value + ? Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n.tracks_in_queue(tracks.length), + style: TextStyle( + color: headlineColor, + fontWeight: FontWeight.bold, + fontSize: 18, + ), ), - ], - ), - ), - ); - }, - ), - ) - else - Flexible( - child: InterScrollbar( - controller: controller, - child: ListView.builder( - controller: controller, - itemCount: filteredTracks.length, - itemBuilder: (context, i) { - final track = filteredTracks.elementAt(i); - return Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - ), - ); - }, + ) + : null, ), ), + actions: [ + if (mediaQuery.mdAndUp || isSearching.value) + TextField( + onChanged: (value) { + searchText.value = value; + }, + decoration: InputDecoration( + hintText: context.l10n.search, + isDense: true, + prefixIcon: mediaQuery.smAndDown + ? IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_outlined, + ), + onPressed: () { + isSearching.value = false; + searchText.value = ''; + }, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size.square(20), + ), + ) + : const Icon(SpotubeIcons.filter), + constraints: BoxConstraints( + maxHeight: 40, + maxWidth: mediaQuery.smAndDown + ? mediaQuery.size.width - 40 + : 300, + ), + ), + ) + else + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.filter), + onPressed: () { + isSearching.value = !isSearching.value; + }, + ), + if (mediaQuery.mdAndUp || !isSearching.value) ...[ + const SizedBox(width: 10), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: + theme.scaffoldBackgroundColor.withOpacity(0.5), + foregroundColor: + theme.textTheme.headlineSmall?.color, + ), + child: Row( + children: [ + const Icon(SpotubeIcons.playlistRemove), + const SizedBox(width: 5), + Text(context.l10n.clear_all), + ], + ), + onPressed: () { + playlistNotifier.stop(); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 10), + ], + ], ), - ], + const SliverGap(10), + SliverReorderableList( + onReorder: (oldIndex, newIndex) { + playlistNotifier.moveTrack(oldIndex, newIndex); + }, + itemCount: filteredTracks.length, + onReorderStart: (index) { + HapticFeedback.selectionClick(); + }, + onReorderEnd: (index) { + HapticFeedback.selectionClick(); + }, + itemBuilder: (context, i) { + final track = filteredTracks.elementAt(i); + return AutoScrollTag( + key: ValueKey(i), + controller: controller, + index: i, + child: Material( + color: Colors.transparent, + child: TrackTile( + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + leadingActions: [ + if (!isSearching.value && + searchText.value.isEmpty) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ReorderableDragStartListener( + index: i, + child: const Icon( + SpotubeIcons.dragHandle, + ), + ), + ), + ], + ), + ), + ); + }, + ), + const SliverGap(100), + ], + ), ), ), ), From 68374efd3ec556f31b937e5b96920787b54eec78 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 4 Apr 2024 22:22:00 +0600 Subject: [PATCH 027/261] feat: LAN connect a.k.a control remote Spotube playback and local output device selection (#1355) * feat: add connect server support * feat: add ability discover and connect to same network Spotube(s) and sync queue * feat(connect): add player controls, shuffle, loop, progress bar and queue support * feat: make control page adaptive * feat: add volume control support * cd: upgrade macos runner version * chore: upgrade inappwebview version to 6 * feat: customized devices button * feat: add user icon next to devices button * feat: add play in remote device support * feat: show alert when new client connects * fix: ignore the device itself from broadcast list * fix: volume control not working * feat: add ability to select current device's output speaker --- .github/workflows/spotube-release-binary.yml | 4 +- CONTRIBUTION.md | 8 +- ios/Podfile | 2 +- ios/Podfile.lock | 31 +- ios/Runner/Info.plist | 6 + lib/collections/routes.dart | 17 + lib/collections/spotube_icons.dart | 5 + lib/components/album/album_card.dart | 18 +- lib/components/connect/connect_device.dart | 85 ++++ lib/components/connect/local_devices.dart | 60 +++ lib/components/library/user_local_tracks.dart | 14 +- lib/components/player/player.dart | 50 ++- lib/components/player/player_controls.dart | 24 +- lib/components/player/player_overlay.dart | 2 +- lib/components/player/player_queue.dart | 399 ++++++++++-------- .../player/player_track_details.dart | 8 +- lib/components/player/volume_slider.dart | 34 +- lib/components/playlist/playlist_card.dart | 18 +- lib/components/root/bottom_player.dart | 21 +- .../shared/dialogs/select_device_dialog.dart | 70 +++ .../shared/page_window_title_bar.dart | 98 ++++- .../shared/track_tile/track_tile.dart | 9 +- .../sections/body/track_view_body.dart | 50 ++- .../sections/header/header_buttons.dart | 44 +- lib/l10n/app_en.arb | 9 +- lib/main.dart | 6 + lib/models/connect/connect.dart | 16 + lib/models/connect/connect.freezed.dart | 216 ++++++++++ lib/models/connect/connect.g.dart | 25 ++ lib/models/connect/load.dart | 27 ++ lib/models/connect/ws_event.dart | 374 ++++++++++++++++ lib/pages/artist/section/top_tracks.dart | 49 ++- lib/pages/connect/connect.dart | 93 ++++ lib/pages/connect/control/control.dart | 317 ++++++++++++++ lib/pages/home/home.dart | 20 +- lib/pages/lyrics/mini_lyrics.dart | 13 +- lib/pages/mobile_login/mobile_login.dart | 18 +- lib/pages/root/root_app.dart | 103 +++-- lib/pages/search/sections/tracks.dart | 73 +++- lib/pages/settings/sections/playback.dart | 7 + lib/provider/connect/clients.dart | 111 +++++ lib/provider/connect/connect.dart | 184 ++++++++ lib/provider/connect/server.dart | 261 ++++++++++++ .../proxy_playlist/proxy_playlist.dart | 14 +- .../user_preferences_provider.dart | 4 + .../user_preferences_state.dart | 1 + .../user_preferences_state.freezed.dart | 37 +- .../user_preferences_state.g.dart | 2 + lib/services/audio_player/audio_player.dart | 11 +- .../audio_player/audio_player_impl.dart | 4 + .../audio_players_streams_mixin.dart | 6 + lib/services/device_info/device_info.dart | 34 ++ linux/packaging/deb/make_config.yaml | 5 + linux/packaging/rpm/make_config.yaml | 3 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + macos/Podfile | 2 +- macos/Podfile.lock | 19 +- macos/Runner.xcodeproj/project.pbxproj | 6 +- pubspec.lock | 134 +++++- pubspec.yaml | 9 +- untranslated_messages.json | 199 ++++++++- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 63 files changed, 3090 insertions(+), 407 deletions(-) create mode 100644 lib/components/connect/connect_device.dart create mode 100644 lib/components/connect/local_devices.dart create mode 100644 lib/components/shared/dialogs/select_device_dialog.dart create mode 100644 lib/models/connect/connect.dart create mode 100644 lib/models/connect/connect.freezed.dart create mode 100644 lib/models/connect/connect.g.dart create mode 100644 lib/models/connect/load.dart create mode 100644 lib/models/connect/ws_event.dart create mode 100644 lib/pages/connect/connect.dart create mode 100644 lib/pages/connect/control/control.dart create mode 100644 lib/provider/connect/clients.dart create mode 100644 lib/provider/connect/connect.dart create mode 100644 lib/provider/connect/server.dart create mode 100644 lib/services/device_info/device_info.dart diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 68ea2d67..e05bf75d 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -284,7 +284,7 @@ jobs: macos: - runs-on: macos-12 + runs-on: macos-14 steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.12.0 @@ -349,7 +349,7 @@ jobs: limit-access-to-actor: true iOS: - runs-on: macos-latest + runs-on: macos-14 steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.10.0 diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 13996cea..e859f9e6 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -25,7 +25,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents - [Before Submitting an Enhancement](#before-submitting-an-enhancement) - [How Do I Submit a Good Enhancement Suggestion?](#how-do-i-submit-a-good-enhancement-suggestion) - [Your First Code Contribution](#your-first-code-contribution) - - [Submit translations](#submit-translations) + - [Submit Translations](#submit-translations) ## Code of Conduct @@ -123,16 +123,16 @@ Do the following: - Install Development dependencies in linux - Debian (>=12/Bookworm)/Ubuntu ```bash - $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev + $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan ``` - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Arch/Manjaro ```bash - yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify + yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan ``` - Fedora ```bash - dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel + dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns ``` - Clone the Repo - Create a `.env` in root of the project following the `.env.example` template diff --git a/ios/Podfile b/ios/Podfile index bc3dcaa6..7235f482 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0b75217f..1d048cc9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -5,6 +5,9 @@ PODS: - Flutter - audio_session (0.0.1): - Flutter + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - DKImagePickerController/Core (4.3.4): @@ -44,11 +47,13 @@ PODS: - file_selector_ios (0.0.1): - Flutter - Flutter (1.0.0) - - flutter_inappwebview (0.0.1): + - flutter_broadcasts (0.0.1): - Flutter - - flutter_inappwebview/Core (= 0.0.1) + - flutter_inappwebview_ios (0.0.1): + - Flutter + - flutter_inappwebview_ios/Core (= 0.0.1) - OrderedSet (~> 5.0) - - flutter_inappwebview/Core (0.0.1): + - flutter_inappwebview_ios/Core (0.0.1): - Flutter - OrderedSet (~> 5.0) - flutter_keyboard_visibility (0.0.1): @@ -102,11 +107,13 @@ DEPENDENCIES: - app_links (from `.symlinks/plugins/app_links/ios`) - audio_service (from `.symlinks/plugins/audio_service/ios`) - audio_session (from `.symlinks/plugins/audio_session/ios`) + - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - - flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`) + - flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`) + - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) @@ -142,6 +149,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/audio_service/ios" audio_session: :path: ".symlinks/plugins/audio_session/ios" + bonsoir_darwin: + :path: ".symlinks/plugins/bonsoir_darwin/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" file_picker: @@ -150,8 +159,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/file_selector_ios/ios" Flutter: :path: Flutter - flutter_inappwebview: - :path: ".symlinks/plugins/flutter_inappwebview/ios" + flutter_broadcasts: + :path: ".symlinks/plugins/flutter_broadcasts/ios" + flutter_inappwebview_ios: + :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" flutter_keyboard_visibility: :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" flutter_mailer: @@ -191,13 +202,15 @@ SPEC CHECKSUMS: app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 audio_service: f509d65da41b9521a61f1c404dd58651f265a567 audio_session: 4f3e461722055d21515cf3261b64c973c062f345 - device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_inappwebview: acd4fc0f012cefd09015000c241137d82f01ba62 + flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 + flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef @@ -221,6 +234,6 @@ SPEC CHECKSUMS: Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 -PODFILE CHECKSUM: 5129d2e80ab0dfc533f262cedf032011b1dfe4fd +PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e COCOAPODS: 1.15.2 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8e103cfa..ffd511a4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -66,5 +66,11 @@ UIViewControllerBasedStatusBarAppearance + NSLocalNetworkUsageDescription + To allow other devices on the network control playback of Spotube securely. + NSBonjourServices + + _spotube._tcp + \ No newline at end of file diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 8428aaf3..80067405 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -6,6 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart' hide Search; import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/pages/connect/connect.dart'; +import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/pages/getting_started/getting_started.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genres.dart'; @@ -173,6 +175,21 @@ final routerProvider = Provider((ref) { ); }, ), + GoRoute( + path: "/connect", + pageBuilder: (context, state) => const SpotubePage( + child: ConnectPage(), + ), + routes: [ + GoRoute( + path: "control", + pageBuilder: (context, state) { + return const SpotubePage( + child: ConnectControlPage(), + ); + }, + ) + ]) ], ), GoRoute( diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 98c8ad45..6de21284 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -116,4 +116,9 @@ abstract class SpotubeIcons { static const openCollective = SimpleIcons.opencollective; static const anonymous = FeatherIcons.user; static const history = FeatherIcons.clock; + static const connect = FeatherIcons.link; + static const speaker = FeatherIcons.speaker; + static const monitor = FeatherIcons.monitor; + static const power = FeatherIcons.power; + static const bluetooth = FeatherIcons.bluetooth; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 083c1949..678bfd06 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -72,8 +75,19 @@ class AlbumCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(album.id!); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: fetchedTracks, + collectionId: album.id!, + ), + ); + } else { + await playlistNotifier.load(fetchedTracks, autoPlay: true); + playlistNotifier.addCollection(album.id!); + } } finally { updating.value = false; } diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart new file mode 100644 index 00000000..8ece074f --- /dev/null +++ b/lib/components/connect/connect_device.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectDeviceButton extends HookConsumerWidget { + const ConnectDeviceButton({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme) = Theme.of(context); + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + final connectClients = ref.watch(connectClientsProvider); + + return SizedBox( + height: 40 * pixelRatio, + child: Stack( + alignment: Alignment.centerRight, + fit: StackFit.loose, + children: [ + Center( + child: InkWell( + onTap: () { + ServiceUtils.push(context, "/connect"); + }, + borderRadius: BorderRadius.circular(50), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: colorScheme.primaryContainer, + ), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (connectClients.asData?.value.resolvedService != + null) ...[ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: Colors.greenAccent, + borderRadius: BorderRadius.circular(50), + ), + ), + const Gap(5), + ], + Text(context.l10n.devices), + if (connectClients.asData?.value.services.isNotEmpty == + true) + Text( + " (${connectClients.asData?.value.services.length})", + style: TextStyle( + color: + colorScheme.onPrimaryContainer.withOpacity(0.5), + ), + ), + const Gap(35), + ], + ), + ), + ), + ), + Positioned( + right: 0, + child: IconButton.filled( + icon: const Icon(SpotubeIcons.speaker), + style: IconButton.styleFrom( + visualDensity: VisualDensity.standard, + foregroundColor: colorScheme.onPrimary, + ), + onPressed: () { + ServiceUtils.push(context, "/connect"); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/connect/local_devices.dart b/lib/components/connect/local_devices.dart new file mode 100644 index 00000000..dd7db971 --- /dev/null +++ b/lib/components/connect/local_devices.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gap/gap.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +class ConnectPageLocalDevices extends HookWidget { + const ConnectPageLocalDevices({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData(:textTheme) = Theme.of(context); + final devicesFuture = useFuture(audioPlayer.devices); + final devicesStream = useStream(audioPlayer.devicesStream); + final selectedDeviceFuture = useFuture(audioPlayer.selectedDevice); + final selectedDeviceStream = useStream(audioPlayer.selectedDeviceStream); + + final devices = devicesStream.data ?? devicesFuture.data; + final selectedDevice = + selectedDeviceStream.data ?? selectedDeviceFuture.data; + + if (devices == null) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + return SliverMainAxisGroup( + slivers: [ + const SliverGap(10), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.this_device, + style: textTheme.titleMedium, + ), + ), + ), + const SliverGap(10), + SliverList.separated( + itemCount: devices.length, + separatorBuilder: (context, index) => const Gap(10), + itemBuilder: (context, index) { + final device = devices[index]; + + return Card( + child: ListTile( + leading: const Icon(SpotubeIcons.speaker), + title: Text(device.description), + subtitle: Text(device.name), + selected: selectedDevice == device, + onTap: () => audioPlayer.setAudioDevice(device), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index e2098570..778558f6 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -283,12 +283,17 @@ class UserLocalTracks extends HookConsumerWidget { trackSnapshot.isLoading ? 5 : filteredTracks.length, itemBuilder: (context, index) { if (trackSnapshot.isLoading) { - return TrackTile(track: FakeData.track, index: index); + return TrackTile( + playlist: playlist, + track: FakeData.track, + index: index, + ); } final track = filteredTracks[index]; return TrackTile( index: index, + playlist: playlist, track: track, userPlaylist: false, onTap: () async { @@ -311,8 +316,11 @@ class UserLocalTracks extends HookConsumerWidget { enabled: true, child: ListView.builder( itemCount: 5, - itemBuilder: (context, index) => - TrackTile(track: FakeData.track, index: index), + itemBuilder: (context, index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), ), ), ), diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 5559be73..6dbd9b11 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -26,6 +26,7 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -46,9 +47,7 @@ class PlayerView extends HookConsumerWidget { final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( (value) => value.activeTrack, )); - final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select( - (value) => value.activeTrack is LocalTrack, - )); + final isLocalTrack = currentTrack is LocalTrack; final mediaQuery = MediaQuery.of(context); useEffect(() { @@ -240,7 +239,7 @@ class PlayerView extends HookConsumerWidget { ), if (isLocalTrack) Text( - currentTrack?.artists?.asString() ?? "", + currentTrack.artists?.asString() ?? "", style: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, color: bodyTextColor, @@ -304,10 +303,25 @@ class PlayerView extends HookConsumerWidget { .height * .7, ), - builder: (context) { - return const PlayerQueue( - floating: false); - }, + builder: (context) => Consumer( + builder: (context, ref, _) { + final playlist = ref.watch( + ProxyPlaylistNotifier + .provider, + ); + final playlistNotifier = + ref.read( + ProxyPlaylistNotifier + .notifier, + ); + return PlayerQueue + .fromProxyPlaylistNotifier( + floating: false, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), ); } : null), @@ -365,11 +379,21 @@ class PlayerView extends HookConsumerWidget { enabledThumbRadius: 8, ), ), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: VolumeSlider( - fullWidth: true, - ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16), + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref + .read(volumeProvider.notifier) + .setVolume(value); + }, + ); + }), ), ), ], diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 02cbfff5..0190e2e6 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget { onPressed: playlist.isFetching == true ? null : () async { - switch (audioPlayer.loopMode) { - case PlaybackLoopMode.all: - audioPlayer - .setLoopMode(PlaybackLoopMode.one); - break; - case PlaybackLoopMode.one: - audioPlayer - .setLoopMode(PlaybackLoopMode.none); - break; - case PlaybackLoopMode.none: - audioPlayer - .setLoopMode(PlaybackLoopMode.all); - break; - } + audioPlayer.setLoopMode( + switch (loopMode) { + PlaybackLoopMode.all => + PlaybackLoopMode.one, + PlaybackLoopMode.one => + PlaybackLoopMode.none, + PlaybackLoopMode.none => + PlaybackLoopMode.all, + }, + ); }, ); }), diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 1ad91a52..e2ca9674 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget { width: double.infinity, color: Colors.transparent, child: PlayerTrackDetails( - albumArt: albumArt, + track: playlist.activeTrack, color: textColor, ), ), diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 7641fad5..0bf61da4 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -3,15 +3,13 @@ import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:spotube/collections/fake.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; @@ -20,19 +18,43 @@ import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; class PlayerQueue extends HookConsumerWidget { final bool floating; + final ProxyPlaylist playlist; + + final Future Function(Track track) onJump; + final Future Function(String trackId) onRemove; + final Future Function(int oldIndex, int newIndex) onReorder; + final Future Function() onStop; + const PlayerQueue({ this.floating = true, + required this.playlist, + required this.onJump, + required this.onRemove, + required this.onReorder, + required this.onStop, super.key, }); + PlayerQueue.fromProxyPlaylistNotifier({ + this.floating = true, + required this.playlist, + required ProxyPlaylistNotifier notifier, + super.key, + }) : onJump = notifier.jumpToTrack, + onRemove = notifier.removeTrack, + onReorder = notifier.moveTrack, + onStop = notifier.stop; + @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final mediaQuery = MediaQuery.of(context); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(ProxyPlaylistNotifier.provider); final controller = useAutoScrollController(); final searchText = useState(''); @@ -48,7 +70,6 @@ class PlayerQueue extends HookConsumerWidget { topRight: Radius.circular(10), ); final theme = Theme.of(context); - final mediaQuery = MediaQuery.of(context); final headlineColor = theme.textTheme.headlineSmall?.color; final filteredTracks = useMemoized( @@ -87,198 +108,204 @@ class PlayerQueue extends HookConsumerWidget { return const NotFound(vertical: true); } - return ClipRRect( - borderRadius: borderRadius, - clipBehavior: Clip.hardEdge, - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 15, - sigmaY: 15, - ), - child: Container( - padding: const EdgeInsets.only( - top: 5.0, - ), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), - borderRadius: borderRadius, - ), - child: CallbackShortcuts( - bindings: { - LogicalKeySet(LogicalKeyboardKey.escape): () { - if (!isSearching.value) { - Navigator.of(context).pop(); - } - isSearching.value = false; - searchText.value = ''; - } - }, - child: InterScrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - slivers: [ - if (!floating) - SliverToBoxAdapter( - child: Center( - child: Container( - height: 5, - width: 100, - margin: const EdgeInsets.only(bottom: 5, top: 2), - decoration: BoxDecoration( - color: headlineColor, - borderRadius: BorderRadius.circular(20), - ), - ), - ), - ), - SliverAppBar( - floating: true, - pinned: false, - snap: false, - backgroundColor: Colors.transparent, - elevation: 0, - automaticallyImplyLeading: !isSearching.value, - title: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - ), - child: SizedBox( - height: kToolbarHeight, - child: mediaQuery.mdAndUp || !isSearching.value - ? Align( - alignment: Alignment.centerLeft, - child: Text( - context.l10n.tracks_in_queue(tracks.length), - style: TextStyle( - color: headlineColor, - fontWeight: FontWeight.bold, - fontSize: 18, - ), - ), - ) - : null, - ), - ), - actions: [ - if (mediaQuery.mdAndUp || isSearching.value) - TextField( - onChanged: (value) { - searchText.value = value; - }, - decoration: InputDecoration( - hintText: context.l10n.search, - isDense: true, - prefixIcon: mediaQuery.smAndDown - ? IconButton( - icon: const Icon( - Icons.arrow_back_ios_new_outlined, - ), - onPressed: () { - isSearching.value = false; - searchText.value = ''; - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - minimumSize: const Size.square(20), - ), - ) - : const Icon(SpotubeIcons.filter), - constraints: BoxConstraints( - maxHeight: 40, - maxWidth: mediaQuery.smAndDown - ? mediaQuery.size.width - 40 - : 300, + return LayoutBuilder( + builder: (context, constrains) { + return ClipRRect( + borderRadius: borderRadius, + clipBehavior: Clip.hardEdge, + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 15, + sigmaY: 15, + ), + child: Container( + padding: const EdgeInsets.only( + top: 5.0, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceVariant.withOpacity(0.5), + borderRadius: borderRadius, + ), + child: CallbackShortcuts( + bindings: { + LogicalKeySet(LogicalKeyboardKey.escape): () { + if (!isSearching.value) { + Navigator.of(context).pop(); + } + isSearching.value = false; + searchText.value = ''; + } + }, + child: InterScrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + slivers: [ + if (!floating) + SliverToBoxAdapter( + child: Center( + child: Container( + height: 5, + width: 100, + margin: const EdgeInsets.only(bottom: 5, top: 2), + decoration: BoxDecoration( + color: headlineColor, + borderRadius: BorderRadius.circular(20), + ), ), ), - ) - else - IconButton.filledTonal( - icon: const Icon(SpotubeIcons.filter), - onPressed: () { - isSearching.value = !isSearching.value; - }, ), - if (mediaQuery.mdAndUp || !isSearching.value) ...[ - const SizedBox(width: 10), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: - theme.scaffoldBackgroundColor.withOpacity(0.5), - foregroundColor: - theme.textTheme.headlineSmall?.color, + SliverAppBar( + floating: true, + pinned: false, + snap: false, + backgroundColor: Colors.transparent, + elevation: 0, + automaticallyImplyLeading: !isSearching.value, + title: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, ), - child: Row( - children: [ - const Icon(SpotubeIcons.playlistRemove), - const SizedBox(width: 5), - Text(context.l10n.clear_all), - ], + child: SizedBox( + height: kToolbarHeight, + child: mediaQuery.mdAndUp || !isSearching.value + ? Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n + .tracks_in_queue(tracks.length), + style: TextStyle( + color: headlineColor, + fontWeight: FontWeight.bold, + fontSize: 18, + ), + ), + ) + : null, ), - onPressed: () { - playlistNotifier.stop(); - Navigator.of(context).pop(); - }, ), - const SizedBox(width: 10), - ], + actions: [ + if (mediaQuery.mdAndUp || isSearching.value) + TextField( + onChanged: (value) { + searchText.value = value; + }, + decoration: InputDecoration( + hintText: context.l10n.search, + isDense: true, + prefixIcon: mediaQuery.smAndDown + ? IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_outlined, + ), + onPressed: () { + isSearching.value = false; + searchText.value = ''; + }, + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size.square(20), + ), + ) + : const Icon(SpotubeIcons.filter), + constraints: BoxConstraints( + maxHeight: 40, + maxWidth: mediaQuery.smAndDown + ? mediaQuery.size.width - 40 + : 300, + ), + ), + ) + else + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.filter), + onPressed: () { + isSearching.value = !isSearching.value; + }, + ), + if (mediaQuery.mdAndUp || !isSearching.value) ...[ + const SizedBox(width: 10), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: theme.scaffoldBackgroundColor + .withOpacity(0.5), + foregroundColor: + theme.textTheme.headlineSmall?.color, + ), + child: Row( + children: [ + const Icon(SpotubeIcons.playlistRemove), + const SizedBox(width: 5), + Text(context.l10n.clear_all), + ], + ), + onPressed: () { + playlistNotifier.stop(); + Navigator.of(context).pop(); + }, + ), + const SizedBox(width: 10), + ], + ], + ), + const SliverGap(10), + SliverReorderableList( + onReorder: (oldIndex, newIndex) { + playlistNotifier.moveTrack(oldIndex, newIndex); + }, + itemCount: filteredTracks.length, + onReorderStart: (index) { + HapticFeedback.selectionClick(); + }, + onReorderEnd: (index) { + HapticFeedback.selectionClick(); + }, + itemBuilder: (context, i) { + final track = filteredTracks.elementAt(i); + return AutoScrollTag( + key: ValueKey(i), + controller: controller, + index: i, + child: Material( + color: Colors.transparent, + child: TrackTile( + playlist: playlist, + index: i, + track: track, + onTap: () async { + if (playlist.activeTrack?.id == track.id) { + return; + } + await playlistNotifier.jumpToTrack(track); + }, + leadingActions: [ + if (!isSearching.value && + searchText.value.isEmpty) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ReorderableDragStartListener( + index: i, + child: const Icon( + SpotubeIcons.dragHandle, + ), + ), + ), + ], + ), + ), + ); + }, + ), + const SliverGap(100), ], ), - const SliverGap(10), - SliverReorderableList( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, - itemCount: filteredTracks.length, - onReorderStart: (index) { - HapticFeedback.selectionClick(); - }, - onReorderEnd: (index) { - HapticFeedback.selectionClick(); - }, - itemBuilder: (context, i) { - final track = filteredTracks.elementAt(i); - return AutoScrollTag( - key: ValueKey(i), - controller: controller, - index: i, - child: Material( - color: Colors.transparent, - child: TrackTile( - index: i, - track: track, - onTap: () async { - if (playlist.activeTrack?.id == track.id) { - return; - } - await playlistNotifier.jumpToTrack(track); - }, - leadingActions: [ - if (!isSearching.value && - searchText.value.isEmpty) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: ReorderableDragStartListener( - index: i, - child: const Icon( - SpotubeIcons.dragHandle, - ), - ), - ), - ], - ), - ), - ); - }, - ), - const SliverGap(100), - ], + ), ), ), ), - ), - ), + ); + }, ); } } diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index 95fecdc2..65e40fe6 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -8,13 +8,14 @@ import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { - final String? albumArt; final Color? color; - const PlayerTrackDetails({super.key, this.albumArt, this.color}); + final Track? track; + const PlayerTrackDetails({super.key, this.color, this.track}); @override Widget build(BuildContext context, ref) { @@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget { child: ClipRRect( borderRadius: BorderRadius.circular(4), child: UniversalImage( - path: albumArt ?? "", + path: (track?.album?.images) + .asUrlString(placeholder: ImagePlaceholder.albumArt), placeholder: Assets.albumPlaceholder.path, ), ), diff --git a/lib/components/player/volume_slider.dart b/lib/components/player/volume_slider.dart index 7596a347..102bbef6 100644 --- a/lib/components/player/volume_slider.dart +++ b/lib/components/player/volume_slider.dart @@ -3,37 +3,39 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/provider/volume_provider.dart'; class VolumeSlider extends HookConsumerWidget { final bool fullWidth; + + final double value; + final ValueChanged onChanged; + const VolumeSlider({ super.key, this.fullWidth = false, + required this.value, + required this.onChanged, }); @override Widget build(BuildContext context, ref) { - final volume = ref.watch(volumeProvider); - final volumeNotifier = ref.watch(volumeProvider.notifier); - var slider = Listener( onPointerSignal: (event) async { if (event is PointerScrollEvent) { if (event.scrollDelta.dy > 0) { - final value = volume - .2; - volumeNotifier.setVolume(value < 0 ? 0 : value); + final newValue = value - .2; + onChanged(newValue < 0 ? 0 : newValue); } else { - final value = volume + .2; - volumeNotifier.setVolume(value > 1 ? 1 : value); + final newValue = value + .2; + onChanged(newValue > 1 ? 1 : newValue); } } }, child: Slider( min: 0, max: 1, - value: volume, - onChanged: volumeNotifier.setVolume, + value: value, + onChanged: onChanged, ), ); return Row( @@ -42,20 +44,20 @@ class VolumeSlider extends HookConsumerWidget { children: [ IconButton( icon: Icon( - volume == 0 + value == 0 ? SpotubeIcons.volumeMute - : volume <= 0.2 + : value <= 0.2 ? SpotubeIcons.volumeLow - : volume <= 0.6 + : value <= 0.6 ? SpotubeIcons.volumeMedium : SpotubeIcons.volumeHigh, size: 16, ), onPressed: () { - if (volume == 0) { - volumeNotifier.setVolume(1); + if (value == 0) { + onChanged(1); } else { - volumeNotifier.setVolume(0); + onChanged(0); } }, ), diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 8915e97a..e5b87d6d 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -71,8 +74,19 @@ class PlaylistCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(playlist.id!); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: fetchedTracks, + collectionId: playlist.id!, + ), + ); + } else { + await playlistNotifier.load(fetchedTracks, autoPlay: true); + playlistNotifier.addCollection(playlist.id!); + } } finally { if (context.mounted) { updating.value = false; diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 16633f7c..19fa7c93 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -19,9 +19,11 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/connect/connect.dart' hide volumeProvider; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/utils/platform.dart'; class BottomPlayer extends HookConsumerWidget { @@ -34,6 +36,7 @@ class BottomPlayer extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); + final remoteControl = ref.watch(connectProvider); final mediaQuery = MediaQuery.of(context); @@ -73,7 +76,9 @@ class BottomPlayer extends HookConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded(child: PlayerTrackDetails(albumArt: albumArt)), + Expanded( + child: PlayerTrackDetails(track: playlist.activeTrack), + ), // controls Flexible( flex: 3, @@ -121,10 +126,20 @@ class BottomPlayer extends HookConsumerWidget { Container( height: 40, constraints: const BoxConstraints(maxWidth: 250), - child: const VolumeSlider(), + padding: const EdgeInsets.only(right: 10), + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref.read(volumeProvider.notifier).setVolume(value); + }, + ); + }), ) ], - ) + ), ], ), ), diff --git a/lib/components/shared/dialogs/select_device_dialog.dart b/lib/components/shared/dialogs/select_device_dialog.dart new file mode 100644 index 00000000..cd8dedb7 --- /dev/null +++ b/lib/components/shared/dialogs/select_device_dialog.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/connect/clients.dart'; + +class SelectDeviceDialog extends HookConsumerWidget { + const SelectDeviceDialog({super.key}); + + @override + Widget build(BuildContext context, ref) { + final isRemoteService = useState(false); + + final connectClients = ref.watch(connectClientsProvider); + final remoteService = connectClients.asData!.value.resolvedService!; + + return AlertDialog( + title: const Text("Choose the device:"), + insetPadding: const EdgeInsets.all(16), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "There are multiple device connected.\n" + "Choose the device you want this action to take place", + ), + RadioListTile.adaptive( + title: Text(remoteService.name), + value: true, + groupValue: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = value!; + }, + ), + RadioListTile.adaptive( + title: const Text("This Device"), + value: false, + groupValue: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = !value!; + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(isRemoteService.value); + }, + child: Text(context.l10n.select), + ), + ], + ); + } +} + +Future showSelectDeviceDialog(BuildContext context, WidgetRef ref) async { + final connectClients = ref.read(connectClientsProvider); + + if (connectClients.asData?.value.resolvedService == null) { + return false; + } + + final isRemote = await showDialog( + context: context, + builder: (context) => const SelectDeviceDialog(), + ); + + return isRemote ?? false; +} diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index ff40bac7..f956fa28 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -26,6 +26,8 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget final double? titleWidth; final Widget? title; + final bool _sliver; + const PageWindowTitleBar({ super.key, this.actions, @@ -42,7 +44,38 @@ class PageWindowTitleBar extends StatefulHookConsumerWidget this.titleTextStyle, this.titleWidth, this.toolbarTextStyle, - }); + }) : _sliver = false, + pinned = false, + floating = false, + snap = false, + stretch = false; + + final bool pinned; + final bool floating; + final bool snap; + final bool stretch; + + const PageWindowTitleBar.sliver({ + super.key, + this.actions, + this.title, + this.backgroundColor, + this.actionsIconTheme, + this.automaticallyImplyLeading = false, + this.centerTitle, + this.foregroundColor, + this.leading, + this.leadingWidth, + this.titleSpacing, + this.titleTextStyle, + this.titleWidth, + this.toolbarTextStyle, + this.pinned = false, + this.floating = false, + this.snap = false, + this.stretch = false, + }) : _sliver = true, + toolbarOpacity = 1; @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @@ -64,6 +97,48 @@ class _PageWindowTitleBarState extends ConsumerState { Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); + if (widget._sliver) { + return SliverLayoutBuilder( + builder: (context, constraints) { + final hasFullscreen = + mediaQuery.size.width == constraints.crossAxisExtent; + final hasLeadingOrCanPop = + widget.leading != null || Navigator.canPop(context); + + return SliverPadding( + padding: EdgeInsets.only( + left: DesktopTools.platform.isMacOS && + hasFullscreen && + hasLeadingOrCanPop + ? 65 + : 0, + ), + sliver: SliverAppBar( + leading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + actions: [ + ...?widget.actions, + WindowTitleBarButtons(foregroundColor: widget.foregroundColor), + ], + backgroundColor: widget.backgroundColor, + foregroundColor: widget.foregroundColor, + actionsIconTheme: widget.actionsIconTheme, + centerTitle: widget.centerTitle, + titleSpacing: widget.titleSpacing, + leadingWidth: widget.leadingWidth, + toolbarTextStyle: widget.toolbarTextStyle, + titleTextStyle: widget.titleTextStyle, + title: widget.title, + pinned: widget.pinned, + floating: widget.floating, + snap: widget.snap, + stretch: widget.stretch, + ), + ); + }, + ); + } + return LayoutBuilder(builder: (context, constrains) { final hasFullscreen = mediaQuery.size.width == constrains.maxWidth; final hasLeadingOrCanPop = @@ -349,10 +424,7 @@ class WindowButton extends StatelessWidget { class MinimizeWindowButton extends WindowButton { MinimizeWindowButton( - {super.key, - super.colors, - super.onPressed, - bool? animate}) + {super.key, super.colors, super.onPressed, bool? animate}) : super( animate: animate ?? false, iconBuilder: (buttonContext) => @@ -362,10 +434,7 @@ class MinimizeWindowButton extends WindowButton { class MaximizeWindowButton extends WindowButton { MaximizeWindowButton( - {super.key, - super.colors, - super.onPressed, - bool? animate}) + {super.key, super.colors, super.onPressed, bool? animate}) : super( animate: animate ?? false, iconBuilder: (buttonContext) => @@ -374,11 +443,7 @@ class MaximizeWindowButton extends WindowButton { } class RestoreWindowButton extends WindowButton { - RestoreWindowButton( - {super.key, - super.colors, - super.onPressed, - bool? animate}) + RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate}) : super( animate: animate ?? false, iconBuilder: (buttonContext) => @@ -394,10 +459,7 @@ final _defaultCloseButtonColors = WindowButtonColors( class CloseWindowButton extends WindowButton { CloseWindowButton( - {super.key, - WindowButtonColors? colors, - super.onPressed, - bool? animate}) + {super.key, WindowButtonColors? colors, super.onPressed, bool? animate}) : super( colors: colors ?? _defaultCloseButtonColors, animate: animate ?? false, diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 897abdae..61061d24 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -18,7 +18,7 @@ import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; class TrackTile extends HookConsumerWidget { /// [index] will not be shown if null @@ -30,6 +30,7 @@ class TrackTile extends HookConsumerWidget { final VoidCallback? onLongPress; final bool userPlaylist; final String? playlistId; + final ProxyPlaylist playlist; final List? leadingActions; @@ -38,6 +39,7 @@ class TrackTile extends HookConsumerWidget { this.index, required this.track, this.selected = false, + required this.playlist, this.onTap, this.onLongPress, this.onChanged, @@ -48,7 +50,6 @@ class TrackTile extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); final theme = Theme.of(context); final blacklist = ref.watch(BlackListNotifier.provider); @@ -65,10 +66,10 @@ class TrackTile extends HookConsumerWidget { final showOptionCbRef = useRef?>(null); - final isPlaying = track.id == playlist.activeTrack?.id; - final isLoading = useState(false); + final isPlaying = playlist.activeTrack?.id == track.id; + final isSelected = isPlaying || isLoading.value; return LayoutBuilder(builder: (context, constrains) { diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index 661e5af4..80368445 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -8,12 +8,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @@ -89,6 +92,7 @@ class TrackViewBodySection extends HookConsumerWidget { loadingBuilder: (context) => Skeletonizer( enabled: true, child: TrackTile( + playlist: playlist, track: FakeData.track, index: 0, ), @@ -98,13 +102,18 @@ class TrackViewBodySection extends HookConsumerWidget { child: Column( children: List.generate( 10, - (index) => TrackTile(track: FakeData.track, index: index), + (index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), ), ), ), itemBuilder: (context, index) { final track = tracks[index]; return TrackTile( + playlist: playlist, track: track, index: index, selected: trackViewState.selectedTrackIds.contains(track.id!), @@ -125,16 +134,37 @@ class TrackViewBodySection extends HookConsumerWidget { return; } - if (isActive || playlist.tracks.contains(track)) { - await playlistNotifier.jumpToTrack(track); + final isRemoteDevice = + await showSelectDeviceDialog(context, ref); + + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remoteQueue = ref.read(queueProvider); + if (remoteQueue.collections.contains(props.collectionId) || + remoteQueue.tracks.any((s) => s.id == track.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: tracks, + collectionId: props.collectionId, + initialIndex: index, + ), + ); + } } else { - final tracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - tracks, - initialIndex: index, - autoPlay: true, - ); - playlistNotifier.addCollection(props.collectionId); + if (isActive || playlist.tracks.contains(track)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await playlistNotifier.load( + tracks, + initialIndex: index, + autoPlay: true, + ); + playlistNotifier.addCollection(props.collectionId); + } } }, ); diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index 513f7aaa..f505f765 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -6,8 +6,11 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -43,13 +46,25 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - allTracks, - autoPlay: true, - initialIndex: Random().nextInt(allTracks.length), - ); - await audioPlayer.setShuffle(true); - playlistNotifier.addCollection(props.collectionId); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: allTracks, + collectionId: props.collectionId, + initialIndex: Random().nextInt(allTracks.length)), + ); + await remotePlayback.setShuffle(true); + } else { + await playlistNotifier.load( + allTracks, + autoPlay: true, + initialIndex: Random().nextInt(allTracks.length), + ); + await audioPlayer.setShuffle(true); + playlistNotifier.addCollection(props.collectionId); + } } finally { isLoading.value = false; } @@ -61,8 +76,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); - await playlistNotifier.load(allTracks, autoPlay: true); - playlistNotifier.addCollection(props.collectionId); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: allTracks, + collectionId: props.collectionId, + ), + ); + } else { + await playlistNotifier.load(allTracks, autoPlay: true); + playlistNotifier.addCollection(props.collectionId); + } } finally { isLoading.value = false; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8257eac9..832862c0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", "contribute_on_github": "Contribute on GitHub", "donate_on_open_collective": "Donate on Open Collective", - "browse_anonymously": "Browse Anonymously" + "browse_anonymously": "Browse Anonymously", + "enable_connect": "Enable Connect", + "enable_connect_description": "Control Spotube from other devices", + "devices": "Devices", + "select": "Select", + "connect_client_alert": "You're being controlled by {client}", + "this_device": "This Device", + "remote": "Remote" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 5c100fd3..2a2d8d18 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,9 @@ import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/source_match.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -180,6 +183,9 @@ class SpotubeState extends ConsumerState { ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); final router = ref.watch(routerProvider); + ref.listen(connectServerProvider, (_, __) {}); + ref.listen(connectClientsProvider, (_, __) {}); + useDisableBatteryOptimizations(); useInitSysTray(ref); useDeepLinking(ref); diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart new file mode 100644 index 00000000..efb37315 --- /dev/null +++ b/lib/models/connect/connect.dart @@ -0,0 +1,16 @@ +library connect; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; + +part 'connect.freezed.dart'; +part 'connect.g.dart'; + +part 'ws_event.dart'; +part 'load.dart'; diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart new file mode 100644 index 00000000..dcbd783d --- /dev/null +++ b/lib/models/connect/connect.freezed.dart @@ -0,0 +1,216 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'connect.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +WebSocketLoadEventData _$WebSocketLoadEventDataFromJson( + Map json) { + return _WebSocketLoadEventData.fromJson(json); +} + +/// @nodoc +mixin _$WebSocketLoadEventData { + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks => throw _privateConstructorUsedError; + String? get collectionId => throw _privateConstructorUsedError; + int? get initialIndex => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $WebSocketLoadEventDataCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $WebSocketLoadEventDataCopyWith<$Res> { + factory $WebSocketLoadEventDataCopyWith(WebSocketLoadEventData value, + $Res Function(WebSocketLoadEventData) then) = + _$WebSocketLoadEventDataCopyWithImpl<$Res, WebSocketLoadEventData>; + @useResult + $Res call( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + String? collectionId, + int? initialIndex}); +} + +/// @nodoc +class _$WebSocketLoadEventDataCopyWithImpl<$Res, + $Val extends WebSocketLoadEventData> + implements $WebSocketLoadEventDataCopyWith<$Res> { + _$WebSocketLoadEventDataCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tracks = null, + Object? collectionId = freezed, + Object? initialIndex = freezed, + }) { + return _then(_value.copyWith( + tracks: null == tracks + ? _value.tracks + : tracks // ignore: cast_nullable_to_non_nullable + as List, + collectionId: freezed == collectionId + ? _value.collectionId + : collectionId // ignore: cast_nullable_to_non_nullable + as String?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$WebSocketLoadEventDataImplCopyWith<$Res> + implements $WebSocketLoadEventDataCopyWith<$Res> { + factory _$$WebSocketLoadEventDataImplCopyWith( + _$WebSocketLoadEventDataImpl value, + $Res Function(_$WebSocketLoadEventDataImpl) then) = + __$$WebSocketLoadEventDataImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + String? collectionId, + int? initialIndex}); +} + +/// @nodoc +class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> + extends _$WebSocketLoadEventDataCopyWithImpl<$Res, + _$WebSocketLoadEventDataImpl> + implements _$$WebSocketLoadEventDataImplCopyWith<$Res> { + __$$WebSocketLoadEventDataImplCopyWithImpl( + _$WebSocketLoadEventDataImpl _value, + $Res Function(_$WebSocketLoadEventDataImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tracks = null, + Object? collectionId = freezed, + Object? initialIndex = freezed, + }) { + return _then(_$WebSocketLoadEventDataImpl( + tracks: null == tracks + ? _value._tracks + : tracks // ignore: cast_nullable_to_non_nullable + as List, + collectionId: freezed == collectionId + ? _value.collectionId + : collectionId // ignore: cast_nullable_to_non_nullable + as String?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { + _$WebSocketLoadEventDataImpl( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + this.collectionId, + this.initialIndex}) + : _tracks = tracks; + + factory _$WebSocketLoadEventDataImpl.fromJson(Map json) => + _$$WebSocketLoadEventDataImplFromJson(json); + + final List _tracks; + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks { + if (_tracks is EqualUnmodifiableListView) return _tracks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tracks); + } + + @override + final String? collectionId; + @override + final int? initialIndex; + + @override + String toString() { + return 'WebSocketLoadEventData(tracks: $tracks, collectionId: $collectionId, initialIndex: $initialIndex)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$WebSocketLoadEventDataImpl && + const DeepCollectionEquality().equals(other._tracks, _tracks) && + (identical(other.collectionId, collectionId) || + other.collectionId == collectionId) && + (identical(other.initialIndex, initialIndex) || + other.initialIndex == initialIndex)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(_tracks), collectionId, initialIndex); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + get copyWith => __$$WebSocketLoadEventDataImplCopyWithImpl< + _$WebSocketLoadEventDataImpl>(this, _$identity); + + @override + Map toJson() { + return _$$WebSocketLoadEventDataImplToJson( + this, + ); + } +} + +abstract class _WebSocketLoadEventData implements WebSocketLoadEventData { + factory _WebSocketLoadEventData( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + final String? collectionId, + final int? initialIndex}) = _$WebSocketLoadEventDataImpl; + + factory _WebSocketLoadEventData.fromJson(Map json) = + _$WebSocketLoadEventDataImpl.fromJson; + + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks; + @override + String? get collectionId; + @override + int? get initialIndex; + @override + @JsonKey(ignore: true) + _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/models/connect/connect.g.dart b/lib/models/connect/connect.g.dart new file mode 100644 index 00000000..f636e035 --- /dev/null +++ b/lib/models/connect/connect.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'connect.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson( + Map json) => + _$WebSocketLoadEventDataImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(e as Map)) + .toList(), + collectionId: json['collectionId'] as String?, + initialIndex: json['initialIndex'] as int?, + ); + +Map _$$WebSocketLoadEventDataImplToJson( + _$WebSocketLoadEventDataImpl instance) => + { + 'tracks': _tracksJson(instance.tracks), + 'collectionId': instance.collectionId, + 'initialIndex': instance.initialIndex, + }; diff --git a/lib/models/connect/load.dart b/lib/models/connect/load.dart new file mode 100644 index 00000000..d750cddd --- /dev/null +++ b/lib/models/connect/load.dart @@ -0,0 +1,27 @@ +part of 'connect.dart'; + +List> _tracksJson(List tracks) { + return tracks.map((e) => e.toJson()).toList(); +} + +@freezed +class WebSocketLoadEventData with _$WebSocketLoadEventData { + factory WebSocketLoadEventData({ + @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, + String? collectionId, + int? initialIndex, + }) = _WebSocketLoadEventData; + + factory WebSocketLoadEventData.fromJson(Map json) => + _$WebSocketLoadEventDataFromJson(json); +} + +class WebSocketLoadEvent extends WebSocketEvent { + WebSocketLoadEvent(WebSocketLoadEventData data) : super(WsEvent.load, data); + + factory WebSocketLoadEvent.fromJson(Map json) { + return WebSocketLoadEvent( + WebSocketLoadEventData.fromJson(json['data'] as Map), + ); + } +} diff --git a/lib/models/connect/ws_event.dart b/lib/models/connect/ws_event.dart new file mode 100644 index 00000000..2d7213b1 --- /dev/null +++ b/lib/models/connect/ws_event.dart @@ -0,0 +1,374 @@ +part of 'connect.dart'; + +enum WsEvent { + error, + volume, + removeTrack, + addTrack, + reorder, + shuffle, + loop, + seek, + duration, + queue, + position, + playing, + resume, + pause, + load, + next, + previous, + jump, + stop; + + static WsEvent fromString(String value) { + return WsEvent.values.firstWhere((e) => e.name == value); + } +} + +typedef EventCallback = FutureOr Function(T event); + +class WebSocketEvent { + final WsEvent type; + final T data; + + WebSocketEvent(this.type, this.data); + + factory WebSocketEvent.fromJson( + Map json, + T Function(dynamic) fromJson, + ) { + return WebSocketEvent( + WsEvent.fromString(json["type"]), + fromJson(json["data"]), + ); + } + + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data, + }); + } + + Future onPosition( + EventCallback callback, + ) async { + if (type == WsEvent.position) { + await callback(WebSocketPositionEvent.fromJson({"data": data})); + } + } + + Future onPlaying( + EventCallback callback, + ) async { + if (type == WsEvent.playing) { + await callback(WebSocketPlayingEvent(data as bool)); + } + } + + Future onResume( + EventCallback callback, + ) async { + if (type == WsEvent.resume) { + await callback(WebSocketResumeEvent()); + } + } + + Future onPause( + EventCallback callback, + ) async { + if (type == WsEvent.pause) { + await callback(WebSocketPauseEvent()); + } + } + + Future onStop( + EventCallback callback, + ) async { + if (type == WsEvent.stop) { + await callback(WebSocketStopEvent()); + } + } + + Future onLoad( + EventCallback callback, + ) async { + if (type == WsEvent.load) { + await callback( + WebSocketLoadEvent( + WebSocketLoadEventData.fromJson(data as Map), + ), + ); + } + } + + Future onNext( + EventCallback callback, + ) async { + if (type == WsEvent.next) { + await callback(WebSocketNextEvent()); + } + } + + Future onPrevious( + EventCallback callback, + ) async { + if (type == WsEvent.previous) { + await callback(WebSocketPreviousEvent()); + } + } + + Future onJump( + EventCallback callback, + ) async { + if (type == WsEvent.jump) { + await callback(WebSocketJumpEvent(data as int)); + } + } + + Future onError( + EventCallback callback, + ) async { + if (type == WsEvent.error) { + await callback(WebSocketErrorEvent(data as String)); + } + } + + Future onQueue( + EventCallback callback, + ) async { + if (type == WsEvent.queue) { + await callback( + WebSocketQueueEvent.fromJson(data as Map), + ); + } + } + + Future onDuration( + EventCallback callback, + ) async { + if (type == WsEvent.duration) { + await callback( + WebSocketDurationEvent( + Duration(seconds: data as int), + ), + ); + } + } + + Future onSeek( + EventCallback callback, + ) async { + if (type == WsEvent.seek) { + await callback( + WebSocketSeekEvent( + Duration(seconds: data as int), + ), + ); + } + } + + Future onShuffle( + EventCallback callback, + ) async { + if (type == WsEvent.shuffle) { + await callback(WebSocketShuffleEvent(data as bool)); + } + } + + Future onLoop( + EventCallback callback, + ) async { + if (type == WsEvent.loop) { + await callback( + WebSocketLoopEvent( + PlaybackLoopMode.fromString(data as String), + ), + ); + } + } + + Future onRemoveTrack( + EventCallback callback, + ) async { + if (type == WsEvent.removeTrack) { + await callback(WebSocketRemoveTrackEvent(data as String)); + } + } + + Future onAddTrack( + EventCallback callback, + ) async { + if (type == WsEvent.addTrack) { + await callback( + WebSocketAddTrackEvent.fromJson(data as Map)); + } + } + + Future onReorder( + EventCallback callback, + ) async { + if (type == WsEvent.reorder) { + await callback( + WebSocketReorderEvent.fromJson(data as Map)); + } + } + + Future onVolume( + EventCallback callback, + ) async { + if (type == WsEvent.volume) { + await callback(WebSocketVolumeEvent(data as double)); + } + } +} + +class WebSocketLoopEvent extends WebSocketEvent { + WebSocketLoopEvent(PlaybackLoopMode data) : super(WsEvent.loop, data); + + WebSocketLoopEvent.fromJson(Map json) + : super( + WsEvent.loop, PlaybackLoopMode.fromString(json["data"] as String)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.name, + }); + } +} + +class WebSocketPositionEvent extends WebSocketEvent { + WebSocketPositionEvent(Duration data) : super(WsEvent.position, data); + + WebSocketPositionEvent.fromJson(Map json) + : super(WsEvent.position, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketDurationEvent extends WebSocketEvent { + WebSocketDurationEvent(Duration data) : super(WsEvent.duration, data); + + WebSocketDurationEvent.fromJson(Map json) + : super(WsEvent.duration, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketSeekEvent extends WebSocketEvent { + WebSocketSeekEvent(Duration data) : super(WsEvent.seek, data); + + WebSocketSeekEvent.fromJson(Map json) + : super(WsEvent.seek, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketShuffleEvent extends WebSocketEvent { + WebSocketShuffleEvent(bool data) : super(WsEvent.shuffle, data); +} + +class WebSocketPlayingEvent extends WebSocketEvent { + WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data); +} + +class WebSocketResumeEvent extends WebSocketEvent { + WebSocketResumeEvent() : super(WsEvent.resume, null); +} + +class WebSocketPauseEvent extends WebSocketEvent { + WebSocketPauseEvent() : super(WsEvent.pause, null); +} + +class WebSocketStopEvent extends WebSocketEvent { + WebSocketStopEvent() : super(WsEvent.stop, null); +} + +class WebSocketNextEvent extends WebSocketEvent { + WebSocketNextEvent() : super(WsEvent.next, null); +} + +class WebSocketPreviousEvent extends WebSocketEvent { + WebSocketPreviousEvent() : super(WsEvent.previous, null); +} + +class WebSocketJumpEvent extends WebSocketEvent { + WebSocketJumpEvent(int data) : super(WsEvent.jump, data); +} + +class WebSocketErrorEvent extends WebSocketEvent { + WebSocketErrorEvent(String data) : super(WsEvent.error, data); +} + +class WebSocketQueueEvent extends WebSocketEvent { + WebSocketQueueEvent(ProxyPlaylist data) : super(WsEvent.queue, data); + + factory WebSocketQueueEvent.fromJson(Map json) => + WebSocketQueueEvent( + ProxyPlaylist.fromJsonRaw(json), + ); +} + +class WebSocketRemoveTrackEvent extends WebSocketEvent { + WebSocketRemoveTrackEvent(String data) : super(WsEvent.removeTrack, data); +} + +class WebSocketAddTrackEvent extends WebSocketEvent { + WebSocketAddTrackEvent(Track data) : super(WsEvent.addTrack, data); + + WebSocketAddTrackEvent.fromJson(Map json) + : super( + WsEvent.addTrack, + Track.fromJson(json["data"] as Map), + ); +} + +typedef ReorderData = ({int oldIndex, int newIndex}); + +class WebSocketReorderEvent extends WebSocketEvent { + WebSocketReorderEvent(ReorderData data) : super(WsEvent.reorder, data); + + factory WebSocketReorderEvent.fromJson(Map json) => + WebSocketReorderEvent( + ( + oldIndex: json["oldIndex"] as int, + newIndex: json["newIndex"] as int, + ), + ); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": { + "oldIndex": data.oldIndex, + "newIndex": data.newIndex, + }, + }); + } +} + +class WebSocketVolumeEvent extends WebSocketEvent { + WebSocketVolumeEvent(double data) : super(WsEvent.volume, data); +} diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 173ace54..9dec5f7c 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -4,8 +4,11 @@ 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/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -39,16 +42,41 @@ class ArtistPageTopTracks extends HookConsumerWidget { void playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; - if (!isPlaylistPlaying) { - playlistNotifier.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playlistNotifier.jumpToTrack(currentTrack); + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remotePlaylist = ref.read(queueProvider); + + final isPlaylistPlaying = remotePlaylist.containsTracks(tracks); + + if (!isPlaylistPlaying) { + await remotePlayback.load( + WebSocketLoadEventData( + tracks: tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + ), + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != remotePlaylist.activeTrack?.id) { + final index = playlist.tracks + .toList() + .indexWhere((s) => s.id == currentTrack!.id); + await remotePlayback.jumpTo(index); + } + } else { + if (!isPlaylistPlaying) { + playlistNotifier.load( + tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playlistNotifier.jumpToTrack(currentTrack); + } } } @@ -107,6 +135,7 @@ class ArtistPageTopTracks extends HookConsumerWidget { final track = topTracks.elementAt(index); return TrackTile( index: index, + playlist: playlist, track: track, onTap: () async { playPlaylist( diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart new file mode 100644 index 00000000..170a0c72 --- /dev/null +++ b/lib/pages/connect/connect.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/connect/local_devices.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectPage extends HookConsumerWidget { + const ConnectPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme, :textTheme) = Theme.of(context); + + final connectClients = ref.watch(connectClientsProvider); + final connectClientsNotifier = ref.read(connectClientsProvider.notifier); + final discoveredDevices = connectClients.asData?.value.services; + + return Scaffold( + appBar: PageWindowTitleBar( + automaticallyImplyLeading: true, + title: Text(context.l10n.devices), + ), + body: ListTileTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + selectedTileColor: colorScheme.secondary.withOpacity(0.1), + child: Padding( + padding: const EdgeInsets.all(10.0), + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + sliver: SliverToBoxAdapter( + child: Text( + context.l10n.remote, + style: textTheme.titleMedium, + ), + ), + ), + const SliverGap(10), + SliverList.separated( + itemCount: discoveredDevices?.length ?? 0, + separatorBuilder: (context, index) => const Gap(10), + itemBuilder: (context, index) { + final device = discoveredDevices![index]; + final selected = + connectClients.asData?.value.resolvedService?.name == + device.name; + return Card( + child: ListTile( + leading: const Icon(SpotubeIcons.monitor), + title: Text(device.name), + subtitle: selected + ? Text( + "${connectClients.asData?.value.resolvedService?.host}" + ":${connectClients.asData?.value.resolvedService?.port}", + ) + : null, + selected: selected, + onTap: () { + if (selected) { + ServiceUtils.push( + context, + "/connect/control", + ); + } else { + connectClientsNotifier.resolveService(device); + } + }, + trailing: selected + ? IconButton( + icon: const Icon(SpotubeIcons.power), + onPressed: () => + connectClientsNotifier.clearResolvedService(), + ) + : null, + ), + ); + }, + ), + const ConnectPageLocalDevices(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart new file mode 100644 index 00000000..16256568 --- /dev/null +++ b/lib/pages/connect/control/control.dart @@ -0,0 +1,317 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/player/player_queue.dart'; +import 'package:spotube/components/player/volume_slider.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/components/shared/links/artist_link.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/extensions/duration.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectControlPage extends HookConsumerWidget { + const ConnectControlPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + + final resolvedService = + ref.watch(connectClientsProvider).asData?.value.resolvedService; + final connectNotifier = ref.read(connectProvider.notifier); + final playlist = ref.watch(queueProvider); + final playing = ref.watch(playingProvider); + final shuffled = ref.watch(shuffleProvider); + final loopMode = ref.watch(loopModeProvider); + + final resumePauseStyle = IconButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + padding: const EdgeInsets.all(12), + iconSize: 24, + ); + final buttonStyle = IconButton.styleFrom( + backgroundColor: colorScheme.surface.withOpacity(0.4), + minimumSize: const Size(28, 28), + ); + + final activeButtonStyle = IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + minimumSize: const Size(28, 28), + ); + + final playerQueue = Consumer(builder: (context, ref, _) { + final playlist = ref.watch(queueProvider); + return PlayerQueue( + playlist: playlist, + floating: true, + onJump: (track) async { + final index = playlist.tracks.toList().indexOf(track); + connectNotifier.jumpTo(index); + }, + onRemove: (track) async { + await connectNotifier.removeTrack(track); + }, + onStop: () async => connectNotifier.stop(), + onReorder: (oldIndex, newIndex) async { + await connectNotifier.reorder( + (oldIndex: oldIndex, newIndex: newIndex), + ); + }, + ); + }); + + ref.listen(connectClientsProvider, (prev, next) { + if (next.asData?.value.resolvedService == null) { + context.pop(); + } + }); + + return SafeArea( + child: Scaffold( + appBar: PageWindowTitleBar( + title: Text(resolvedService!.name), + automaticallyImplyLeading: true, + ), + body: LayoutBuilder(builder: (context, constrains) { + return Row( + children: [ + Expanded( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ).copyWith(top: 0), + constraints: + const BoxConstraints(maxHeight: 400, maxWidth: 400), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: UniversalImage( + path: (playlist.activeTrack?.album?.images) + .asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: AnchorButton( + playlist.activeTrack?.name ?? "", + style: textTheme.titleLarge!, + onTap: () { + ServiceUtils.push( + context, + "/track/${playlist.activeTrack?.id}", + ); + }, + ), + ), + SliverToBoxAdapter( + child: ArtistLink( + artists: playlist.activeTrack?.artists ?? [], + textStyle: textTheme.bodyMedium!, + mainAxisAlignment: WrapAlignment.start, + ), + ), + ], + ), + ), + const SliverGap(30), + SliverToBoxAdapter( + child: Consumer( + builder: (context, ref, _) { + final position = ref.watch(positionProvider); + final duration = ref.watch(durationProvider); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + children: [ + Slider( + value: position > duration + ? 0 + : position.inSeconds.toDouble(), + min: 0, + max: duration.inSeconds.toDouble(), + onChanged: (value) { + connectNotifier + .seek(Duration(seconds: value.toInt())); + }, + ), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text(position.toHumanReadableString()), + Text(duration.toHumanReadableString()), + ], + ), + ], + ), + ); + }, + ), + ), + SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + tooltip: shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + icon: const Icon(SpotubeIcons.shuffle), + style: shuffled ? activeButtonStyle : buttonStyle, + onPressed: playlist.activeTrack == null + ? null + : () { + connectNotifier.setShuffle(!shuffled); + }, + ), + IconButton( + tooltip: context.l10n.previous_track, + icon: const Icon(SpotubeIcons.skipBack), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.previous, + ), + IconButton( + tooltip: playing + ? context.l10n.pause_playback + : context.l10n.resume_playback, + icon: playlist.activeTrack == null + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: colorScheme.onPrimary, + ), + ) + : Icon( + playing + ? SpotubeIcons.pause + : SpotubeIcons.play, + ), + style: resumePauseStyle, + onPressed: playlist.activeTrack == null + ? null + : () { + if (playing) { + connectNotifier.pause(); + } else { + connectNotifier.resume(); + } + }, + ), + IconButton( + tooltip: context.l10n.next_track, + icon: const Icon(SpotubeIcons.skipForward), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.next, + ), + IconButton( + tooltip: loopMode == PlaybackLoopMode.one + ? context.l10n.loop_track + : loopMode == PlaybackLoopMode.all + ? context.l10n.repeat_playlist + : null, + icon: Icon( + loopMode == PlaybackLoopMode.one + ? SpotubeIcons.repeatOne + : SpotubeIcons.repeat, + ), + style: loopMode == PlaybackLoopMode.one || + loopMode == PlaybackLoopMode.all + ? activeButtonStyle + : buttonStyle, + onPressed: playlist.activeTrack == null + ? null + : () async { + connectNotifier.setLoopMode( + switch (loopMode) { + PlaybackLoopMode.all => + PlaybackLoopMode.one, + PlaybackLoopMode.one => + PlaybackLoopMode.none, + PlaybackLoopMode.none => + PlaybackLoopMode.all, + }, + ); + }, + ) + ], + ), + ), + const SliverGap(30), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: Consumer(builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref.read(volumeProvider.notifier).state = value; + connectNotifier.setVolume(value); + }, + ); + }), + ), + ), + const SliverGap(30), + if (constrains.mdAndDown) + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: OutlinedButton.icon( + icon: const Icon(SpotubeIcons.queue), + label: Text(context.l10n.queue), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) { + return playerQueue; + }, + ); + }, + ), + ), + ) + ], + ), + ), + if (constrains.lgAndUp) ...[ + const VerticalDivider(thickness: 1), + Expanded( + child: playerQueue, + ), + ] + ], + ); + }), + ), + ); + } +} diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index ed297065..487ceb4c 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,6 +3,8 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/friends.dart'; import 'package:spotube/components/home/sections/genres.dart'; @@ -20,15 +22,21 @@ class HomePage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: - DesktopTools.platform.isLinux || DesktopTools.platform.isWindows - ? const PageWindowTitleBar() - : null, body: CustomScrollView( controller: controller, slivers: [ - if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb) - const SliverGap(20), + PageWindowTitleBar.sliver( + pinned: DesktopTools.platform.isDesktop, + actions: [ + const ConnectDeviceButton(), + const Gap(10), + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.user), + onPressed: () {}, + ), + const Gap(10), + ], + ), const HomeGenresSection(), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index a617909c..310df75c 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -221,7 +221,18 @@ class MiniLyricsPage extends HookConsumerWidget { MediaQuery.of(context).size.height * .7, ), builder: (context) { - return const PlayerQueue(floating: true); + return Consumer(builder: (context, ref, _) { + final playlist = ref + .watch(ProxyPlaylistNotifier.provider); + + return PlayerQueue + .fromProxyPlaylistNotifier( + floating: true, + playlist: playlist, + notifier: ref + .read(ProxyPlaylistNotifier.notifier), + ); + }); }, ); } diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index d9a309ed..6260e284 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -27,19 +27,17 @@ class WebViewLogin extends HookConsumerWidget { return Scaffold( body: SafeArea( child: InAppWebView( - initialOptions: InAppWebViewGroupOptions( - crossPlatform: InAppWebViewOptions( - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36", - ), + initialSettings: InAppWebViewSettings( + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36", ), initialUrlRequest: URLRequest( - url: Uri.parse("https://accounts.spotify.com/"), + url: WebUri("https://accounts.spotify.com/"), ), - androidOnPermissionRequest: (controller, origin, resources) async { - return PermissionRequestResponse( - resources: resources, - action: PermissionRequestResponseAction.GRANT, + onPermissionRequest: (controller, permissionRequest) async { + return PermissionResponse( + resources: permissionRequest.resources, + action: PermissionResponseAction.GRANT, ); }, onLoadStop: (controller, action) async { diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index b562adab..2e079200 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -16,7 +16,9 @@ import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/hooks/configurators/use_update_checker.dart'; +import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -53,50 +55,75 @@ class RootApp extends HookConsumerWidget { } }); - final subscription = ConnectionCheckerService - .instance.onConnectivityChanged - .listen((status) { - if (status) { + final subscriptions = [ + ConnectionCheckerService.instance.onConnectivityChanged + .listen((status) { + if (status) { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + SpotubeIcons.wifi, + color: theme.colorScheme.onPrimary, + ), + const SizedBox(width: 10), + Text(context.l10n.connection_restored), + ], + ), + backgroundColor: theme.colorScheme.primary, + showCloseIcon: true, + width: 350, + ), + ); + } else { + scaffoldMessenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon( + SpotubeIcons.noWifi, + color: theme.colorScheme.onError, + ), + const SizedBox(width: 10), + Text(context.l10n.you_are_offline), + ], + ), + backgroundColor: theme.colorScheme.error, + showCloseIcon: true, + width: 300, + ), + ); + } + }), + connectClientStream.listen((clientOrigin) { scaffoldMessenger.showSnackBar( SnackBar( + backgroundColor: Colors.yellow[600], + behavior: SnackBarBehavior.floating, content: Row( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - SpotubeIcons.wifi, - color: theme.colorScheme.onPrimary, + const Icon( + SpotubeIcons.error, + color: Colors.black, ), const SizedBox(width: 10), - Text(context.l10n.connection_restored), - ], - ), - backgroundColor: theme.colorScheme.primary, - showCloseIcon: true, - width: 350, - ), - ); - } else { - scaffoldMessenger.showSnackBar( - SnackBar( - content: Row( - children: [ - Icon( - SpotubeIcons.noWifi, - color: theme.colorScheme.onError, + Text( + context.l10n.connect_client_alert(clientOrigin), + style: const TextStyle(color: Colors.black), ), - const SizedBox(width: 10), - Text(context.l10n.you_are_offline), ], ), - backgroundColor: theme.colorScheme.error, - showCloseIcon: true, - width: 300, ), ); - } - }); + }) + ]; return () { - subscription.cancel(); + for (final subscription in subscriptions) { + subscription.cancel(); + } }; }, []); @@ -191,7 +218,19 @@ class RootApp extends HookConsumerWidget { top: 40, bottom: 100, ), - child: const PlayerQueue(floating: true), + child: Consumer( + builder: (context, ref, _) { + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = + ref.read(ProxyPlaylistNotifier.notifier); + + return PlayerQueue.fromProxyPlaylistNotifier( + floating: true, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), ) : null, bottomNavigationBar: Column( diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 0fdb50af..2152cc45 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -3,8 +3,11 @@ import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -46,26 +49,60 @@ class SearchTracksSection extends HookConsumerWidget { return TrackTile( index: i, track: track, + playlist: playlist, onTap: () async { - final isTrackPlaying = playlist.activeTrack?.id == track.id; - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name!, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; + final isRemoteDevice = + await showSelectDeviceDialog(context, ref); - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remotePlaylist = ref.read(queueProvider); + + final isTrackPlaying = + remotePlaylist.activeTrack?.id == track.id; + + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await remotePlayback.load( + WebSocketLoadEventData( + tracks: [track], + ), + ); + } + } + } else { + final isTrackPlaying = playlist.activeTrack?.id == track.id; + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await playlistNotifier.load( + [track], + autoPlay: true, + ); + } } } }, diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index b3f0d897..e023cc60 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -227,6 +227,13 @@ class SettingsPlaybackSection extends HookConsumerWidget { value: preferences.endlessPlayback, onChanged: preferencesNotifier.setEndlessPlayback, ), + SwitchListTile( + title: Text(context.l10n.enable_connect), + subtitle: Text(context.l10n.enable_connect_description), + secondary: const Icon(SpotubeIcons.connect), + value: preferences.enableConnect, + onChanged: preferencesNotifier.setEnableConnect, + ), ], ); } diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart new file mode 100644 index 00000000..282c96aa --- /dev/null +++ b/lib/provider/connect/clients.dart @@ -0,0 +1,111 @@ +import 'package:bonsoir/bonsoir.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotube/services/device_info/device_info.dart'; + +class ConnectClientsState { + final List services; + final ResolvedBonsoirService? resolvedService; + final BonsoirDiscovery discovery; + + ConnectClientsState({ + required this.services, + required this.discovery, + this.resolvedService, + }); + + ConnectClientsState copyWith({ + List? services, + BonsoirDiscovery? discovery, + ResolvedBonsoirService? resolvedService, + }) { + return ConnectClientsState( + services: services ?? this.services, + discovery: discovery ?? this.discovery, + resolvedService: resolvedService ?? this.resolvedService, + ); + } +} + +class ConnectClientsNotifier extends AsyncNotifier { + ConnectClientsNotifier(); + + @override + build() async { + final discovery = BonsoirDiscovery(type: '_spotube._tcp'); + final deviceId = await DeviceInfoService.instance.deviceId(); + await discovery.ready; + + final subscription = discovery.eventStream?.listen((event) { + // ignore device itself + if (event.service?.attributes["deviceId"] == deviceId) { + return; + } + + switch (event.type) { + case BonsoirDiscoveryEventType.discoveryServiceFound: + state = AsyncData(state.value!.copyWith( + services: [ + ...?state.value?.services, + event.service!, + ], + )); + break; + case BonsoirDiscoveryEventType.discoveryServiceResolved: + state = AsyncData( + state.value!.copyWith( + resolvedService: event.service as ResolvedBonsoirService, + ), + ); + break; + case BonsoirDiscoveryEventType.discoveryServiceLost: + state = AsyncData( + ConnectClientsState( + services: state.value!.services + .where((s) => s.name != event.service!.name) + .toList(), + discovery: state.value!.discovery, + resolvedService: + event.service?.name == state.value!.resolvedService!.name + ? null + : state.value!.resolvedService, + ), + ); + break; + default: + break; + } + }); + + ref.onDispose(() { + subscription?.cancel(); + discovery.stop(); + }); + + await discovery.start(); + + return ConnectClientsState( + services: [], + discovery: discovery, + ); + } + + Future resolveService(BonsoirService service) async { + if (state.value == null) return; + await service.resolve(state.value!.discovery.serviceResolver); + } + + Future clearResolvedService() async { + if (state.value == null) return; + state = AsyncData( + ConnectClientsState( + services: state.value!.services, + discovery: state.value!.discovery, + ), + ); + } +} + +final connectClientsProvider = + AsyncNotifierProvider( + () => ConnectClientsNotifier(), +); diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart new file mode 100644 index 00000000..65daaf55 --- /dev/null +++ b/lib/provider/connect/connect.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:web_socket_channel/status.dart' as status; + +final playingProvider = StateProvider( + (ref) => false, +); + +final positionProvider = StateProvider( + (ref) => Duration.zero, +); + +final durationProvider = StateProvider( + (ref) => Duration.zero, +); + +final shuffleProvider = StateProvider( + (ref) => false, +); + +final loopModeProvider = StateProvider( + (ref) => PlaybackLoopMode.none, +); + +final queueProvider = StateProvider( + (ref) => ProxyPlaylist({}), +); + +final volumeProvider = StateProvider( + (ref) => 1.0, +); + +class ConnectNotifier extends AsyncNotifier { + @override + build() async { + try { + final connectClients = ref.watch(connectClientsProvider); + print('Building ConnectNotifier'); + + if (connectClients.asData?.value.resolvedService == null) return null; + + final service = connectClients.asData!.value.resolvedService!; + + print( + 'Connecting to ${service.name}: ws://${service.host}:${service.port}/ws'); + + final channel = WebSocketChannel.connect( + Uri.parse('ws://${service.host}:${service.port}/ws'), + ); + + await channel.ready; + + print( + 'Connected to ${service.name}: ws://${service.host}:${service.port}/ws'); + + final subscription = channel.stream.listen( + (message) { + final event = + WebSocketEvent.fromJson(jsonDecode(message), (data) => data); + + event.onQueue((event) { + ref.read(queueProvider.notifier).state = event.data; + }); + + event.onPlaying((event) { + ref.read(playingProvider.notifier).state = event.data; + }); + + event.onPosition((event) { + ref.read(positionProvider.notifier).state = event.data; + }); + + event.onDuration((event) { + ref.read(durationProvider.notifier).state = event.data; + }); + + event.onShuffle((event) { + ref.read(shuffleProvider.notifier).state = event.data; + }); + + event.onLoop((event) { + ref.read(loopModeProvider.notifier).state = event.data; + }); + + event.onVolume((event) { + ref.read(volumeProvider.notifier).state = event.data; + }); + }, + onError: (error) { + Catcher2.reportCheckedError( + error, + StackTrace.current, + ); + }, + ); + + ref.onDispose(() { + subscription.cancel(); + channel.sink.close(status.goingAway); + }); + + return channel; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + rethrow; + } + } + + Future emit(Object message) async { + if (state.value == null) return; + state.value?.sink.add( + message is String ? message : (message as dynamic).toJson(), + ); + } + + Future resume() async { + emit(WebSocketResumeEvent()); + } + + Future pause() async { + emit(WebSocketPauseEvent()); + } + + Future stop() async { + emit(WebSocketStopEvent()); + } + + Future jumpTo(int position) async { + emit(WebSocketJumpEvent(position)); + } + + Future load(WebSocketLoadEventData data) async { + emit(WebSocketLoadEvent(data)); + } + + Future next() async { + emit(WebSocketNextEvent()); + } + + Future previous() async { + emit(WebSocketPreviousEvent()); + } + + Future seek(Duration position) async { + emit(WebSocketSeekEvent(position)); + } + + Future setShuffle(bool value) async { + emit(WebSocketShuffleEvent(value)); + } + + Future setLoopMode(PlaybackLoopMode value) async { + emit(WebSocketLoopEvent(value)); + } + + Future addTrack(Track data) async { + emit(WebSocketAddTrackEvent(data)); + } + + Future removeTrack(String data) async { + emit(WebSocketRemoveTrackEvent(data)); + } + + Future reorder(ReorderData data) async { + emit(WebSocketReorderEvent(data)); + } + + Future setVolume(double value) async { + emit(WebSocketVolumeEvent(value)); + } +} + +final connectProvider = + AsyncNotifierProvider( + () => ConnectNotifier(), +); diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart new file mode 100644 index 00000000..0469e3f5 --- /dev/null +++ b/lib/provider/connect/server.dart @@ -0,0 +1,261 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/models/logger.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:bonsoir/bonsoir.dart'; +import 'package:spotube/services/device_info/device_info.dart'; +import 'package:spotube/utils/primitive_utils.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:spotube/provider/volume_provider.dart'; + +final logger = getLogger('ConnectServer'); +final _connectClientStreamController = StreamController.broadcast(); + +Stream get connectClientStream => _connectClientStreamController.stream; + +final connectServerProvider = FutureProvider((ref) async { + final enabled = + ref.watch(userPreferencesProvider.select((s) => s.enableConnect)); + final resolvedService = await ref + .watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); + final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier); + + if (!enabled || resolvedService != null) { + return null; + } + + final app = Router(); + + app.get( + "/ping", + (Request req) { + return Response.ok("pong"); + }, + ); + + final subscriptions = []; + + FutureOr websocket(Request req) => webSocketHandler( + (WebSocketChannel channel, String? protocol) async { + final context = + (req.context["shelf.io.connection_info"] as HttpConnectionInfo?); + final origin = + "${context?.remoteAddress.host}:${context?.remotePort}"; + _connectClientStreamController.add(origin); + + ref.listen( + ProxyPlaylistNotifier.provider, + (previous, next) { + channel.sink.add( + WebSocketQueueEvent(next).toJson(), + ); + }, + fireImmediately: true, + ); + + // because audioPlayer events doesn't fireImmediately + channel.sink.add( + WebSocketPlayingEvent(audioPlayer.isPlaying).toJson(), + ); + channel.sink.add( + WebSocketPositionEvent(await audioPlayer.position ?? Duration.zero) + .toJson(), + ); + channel.sink.add( + WebSocketDurationEvent(await audioPlayer.duration ?? Duration.zero) + .toJson(), + ); + channel.sink.add( + WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(), + ); + channel.sink.add( + WebSocketLoopEvent(audioPlayer.loopMode).toJson(), + ); + channel.sink.add( + WebSocketVolumeEvent(audioPlayer.volume).toJson(), + ); + + subscriptions.addAll([ + audioPlayer.positionStream.listen( + (position) { + channel.sink.add( + WebSocketPositionEvent(position).toJson(), + ); + }, + ), + audioPlayer.playingStream.listen( + (playing) { + channel.sink.add( + WebSocketPlayingEvent(playing).toJson(), + ); + }, + ), + audioPlayer.durationStream.listen( + (duration) { + channel.sink.add( + WebSocketDurationEvent(duration).toJson(), + ); + }, + ), + audioPlayer.shuffledStream.listen( + (shuffled) { + channel.sink.add( + WebSocketShuffleEvent(shuffled).toJson(), + ); + }, + ), + audioPlayer.loopModeStream.listen( + (loopMode) { + channel.sink.add( + WebSocketLoopEvent(loopMode).toJson(), + ); + }, + ), + audioPlayer.volumeStream.listen( + (volume) { + channel.sink.add( + WebSocketVolumeEvent(volume).toJson(), + ); + }, + ), + channel.stream.listen( + (message) { + try { + final event = WebSocketEvent.fromJson( + jsonDecode(message), + (data) => data, + ); + + event.onLoad((event) async { + await playbackNotifier.load( + event.data.tracks, + autoPlay: true, + initialIndex: event.data.initialIndex ?? 0, + ); + + if (event.data.collectionId != null) { + playbackNotifier.addCollection(event.data.collectionId!); + } + }); + + event.onPause((event) async { + await audioPlayer.pause(); + }); + + event.onResume((event) async { + await audioPlayer.resume(); + }); + + event.onStop((event) async { + await audioPlayer.stop(); + }); + + event.onNext((event) async { + await playbackNotifier.next(); + }); + + event.onPrevious((event) async { + await playbackNotifier.previous(); + }); + + event.onJump((event) async { + await playbackNotifier.jumpTo(event.data); + }); + + event.onSeek((event) async { + await audioPlayer.seek(event.data); + }); + + event.onShuffle((event) async { + await audioPlayer.setShuffle(event.data); + }); + + event.onLoop((event) async { + await audioPlayer.setLoopMode(event.data); + }); + + event.onAddTrack((event) async { + await playbackNotifier.addTrack(event.data); + }); + + event.onRemoveTrack((event) async { + await playbackNotifier.removeTrack(event.data); + }); + + event.onReorder((event) async { + await playbackNotifier.moveTrack( + event.data.oldIndex, + event.data.newIndex, + ); + }); + + event.onVolume((event) async { + ref.read(volumeProvider.notifier).setVolume(event.data); + }); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + channel.sink.add(WebSocketErrorEvent(e.toString()).toJson()); + } + }, + onDone: () { + logger.i('Connection closed'); + }, + ), + ]); + }, + )(req); + + final port = Random().nextInt(17000) + 3000; + + final server = await serve( + (request) { + if (request.url.path.startsWith('ws')) { + return websocket(request); + } + return app(request); + }, + InternetAddress.anyIPv4, + port, + ); + + logger.i('Server running on http://${server.address.host}:${server.port}'); + + final service = BonsoirService( + name: await DeviceInfoService.instance.computerName(), + type: '_spotube._tcp', + port: port, + attributes: { + "id": PrimitiveUtils.uuid.v4(), + "deviceId": await DeviceInfoService.instance.deviceId(), + }, + ); + + final broadcast = BonsoirBroadcast(service: service); + + await broadcast.ready; + await broadcast.start(); + + ref.onDispose(() async { + logger.i('Stopping server'); + for (final subscription in subscriptions) { + await subscription.cancel(); + } + await broadcast.stop(); + await server.close(); + }); + + return app; +}); diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index 026b3403..efc818ed 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -27,6 +27,16 @@ class ProxyPlaylist { ); } + factory ProxyPlaylist.fromJsonRaw(Map json) => ProxyPlaylist( + json['tracks'] == null + ? {} + : (json['tracks'] as List).map((t) => Track.fromJson(t)).toSet(), + json['active'] as int?, + json['collections'] == null + ? {} + : (json['collections'] as List).toSet().cast(), + ); + Track? get activeTrack => active == null || active == -1 ? null : tracks.elementAtOrNull(active!); @@ -62,8 +72,8 @@ class ProxyPlaylist { /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { return switch (track.runtimeType) { - LocalTrack => track.toJson(), - SourcedTrack => track.toJson(), + LocalTrack() => track.toJson(), + SourcedTrack() => track.toJson(), _ => track.toJson(), }; } diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index 875f36cc..42b38746 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -127,6 +127,10 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(endlessPlayback: endless); } + void setEnableConnect(bool enable) { + state = state.copyWith(enableConnect: enable); + } + Future _getDefaultDownloadDirectory() async { if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index cf6c0597..e35c73b5 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -91,6 +91,7 @@ class UserPreferences with _$UserPreferences { @Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec, @Default(true) bool discordPresence, @Default(true) bool endlessPlayback, + @Default(false) bool enableConnect, }) = _UserPreferences; factory UserPreferences.fromJson(Map json) => _$UserPreferencesFromJson(json); diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart index 4d08d1a9..a5b076bb 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -50,6 +50,7 @@ mixin _$UserPreferences { SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError; bool get discordPresence => throw _privateConstructorUsedError; bool get endlessPlayback => throw _privateConstructorUsedError; + bool get enableConnect => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -93,7 +94,8 @@ abstract class $UserPreferencesCopyWith<$Res> { SourceCodecs streamMusicCodec, SourceCodecs downloadMusicCodec, bool discordPresence, - bool endlessPlayback}); + bool endlessPlayback, + bool enableConnect}); } /// @nodoc @@ -131,6 +133,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> Object? downloadMusicCodec = null, Object? discordPresence = null, Object? endlessPlayback = null, + Object? enableConnect = null, }) { return _then(_value.copyWith( audioQuality: null == audioQuality @@ -221,6 +224,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> ? _value.endlessPlayback : endlessPlayback // ignore: cast_nullable_to_non_nullable as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, ) as $Val); } } @@ -263,7 +270,8 @@ abstract class _$$UserPreferencesImplCopyWith<$Res> SourceCodecs streamMusicCodec, SourceCodecs downloadMusicCodec, bool discordPresence, - bool endlessPlayback}); + bool endlessPlayback, + bool enableConnect}); } /// @nodoc @@ -299,6 +307,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> Object? downloadMusicCodec = null, Object? discordPresence = null, Object? endlessPlayback = null, + Object? enableConnect = null, }) { return _then(_$UserPreferencesImpl( audioQuality: null == audioQuality @@ -389,6 +398,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> ? _value.endlessPlayback : endlessPlayback // ignore: cast_nullable_to_non_nullable as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, )); } } @@ -426,7 +439,8 @@ class _$UserPreferencesImpl implements _UserPreferences { this.streamMusicCodec = SourceCodecs.weba, this.downloadMusicCodec = SourceCodecs.m4a, this.discordPresence = true, - this.endlessPlayback = true}); + this.endlessPlayback = true, + this.enableConnect = false}); factory _$UserPreferencesImpl.fromJson(Map json) => _$$UserPreferencesImplFromJson(json); @@ -503,10 +517,13 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final bool endlessPlayback; + @override + @JsonKey() + final bool enableConnect; @override String toString() { - return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback)'; + return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; } @override @@ -556,7 +573,9 @@ class _$UserPreferencesImpl implements _UserPreferences { (identical(other.discordPresence, discordPresence) || other.discordPresence == discordPresence) && (identical(other.endlessPlayback, endlessPlayback) || - other.endlessPlayback == endlessPlayback)); + other.endlessPlayback == endlessPlayback) && + (identical(other.enableConnect, enableConnect) || + other.enableConnect == enableConnect)); } @JsonKey(ignore: true) @@ -584,7 +603,8 @@ class _$UserPreferencesImpl implements _UserPreferences { streamMusicCodec, downloadMusicCodec, discordPresence, - endlessPlayback + endlessPlayback, + enableConnect ]); @JsonKey(ignore: true) @@ -633,7 +653,8 @@ abstract class _UserPreferences implements UserPreferences { final SourceCodecs streamMusicCodec, final SourceCodecs downloadMusicCodec, final bool discordPresence, - final bool endlessPlayback}) = _$UserPreferencesImpl; + final bool endlessPlayback, + final bool enableConnect}) = _$UserPreferencesImpl; factory _UserPreferences.fromJson(Map json) = _$UserPreferencesImpl.fromJson; @@ -691,6 +712,8 @@ abstract class _UserPreferences implements UserPreferences { @override bool get endlessPlayback; @override + bool get enableConnect; + @override @JsonKey(ignore: true) _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index ce488247..8bdd12cc 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -59,6 +59,7 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( SourceCodecs.m4a, discordPresence: json['discordPresence'] as bool? ?? true, endlessPlayback: json['endlessPlayback'] as bool? ?? true, + enableConnect: json['enableConnect'] as bool? ?? false, ); Map _$$UserPreferencesImplToJson( @@ -87,6 +88,7 @@ Map _$$UserPreferencesImplToJson( 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, 'discordPresence': instance.discordPresence, 'endlessPlayback': instance.endlessPlayback, + 'enableConnect': instance.enableConnect, }; const _$SourceQualitiesEnumMap = { diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index b3957964..0a22bec1 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,4 +1,5 @@ import 'package:catcher_2/catcher_2.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:spotube/services/audio_player/mk_state_player.dart'; // import 'package:just_audio/just_audio.dart' as ja; import 'dart:async'; @@ -14,7 +15,7 @@ part 'audio_player_impl.dart'; abstract class AudioPlayerInterface { final MkPlayerWithState _mkPlayer; - // final ja.AudioPlayer? _justAudio; + // final ja.AudioPlayer? _justAudxio; AudioPlayerInterface() : _mkPlayer = MkPlayerWithState( @@ -60,6 +61,14 @@ abstract class AudioPlayerInterface { } } + Future get selectedDevice async { + return _mkPlayer.state.audioDevice; + } + + Future> get devices async { + return _mkPlayer.state.audioDevices; + } + bool get hasSource { return _mkPlayer.playlist.medias.isNotEmpty; // if (mkSupportedPlatform) { diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index 2af94dd7..bfa13220 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -83,6 +83,10 @@ class SpotubeAudioPlayer extends AudioPlayerInterface // await _justAudio?.setSpeed(speed); } + Future setAudioDevice(AudioDevice device) async { + await _mkPlayer.setAudioDevice(device); + } + Future dispose() async { await _mkPlayer.dispose(); // await _justAudio?.dispose(); diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index a736dc1c..f05ba5ef 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -140,4 +140,10 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // .cast(); // } } + + Stream> get devicesStream => + _mkPlayer.stream.audioDevices.asBroadcastStream(); + + Stream get selectedDeviceStream => + _mkPlayer.stream.audioDevice.asBroadcastStream(); } diff --git a/lib/services/device_info/device_info.dart b/lib/services/device_info/device_info.dart new file mode 100644 index 00000000..87ddd6eb --- /dev/null +++ b/lib/services/device_info/device_info.dart @@ -0,0 +1,34 @@ +import 'package:device_info_plus/device_info_plus.dart'; + +class DeviceInfoService { + final DeviceInfoPlugin deviceInfo; + DeviceInfoService._() : deviceInfo = DeviceInfoPlugin(); + + static final instance = DeviceInfoService._(); + + Future deviceId() async { + final info = await deviceInfo.deviceInfo; + + return switch (info) { + AndroidDeviceInfo() => info.id, + IosDeviceInfo() => info.identifierForVendor ?? info.model, + MacOsDeviceInfo() => info.systemGUID ?? info.model, + WindowsDeviceInfo() => info.deviceId, + LinuxDeviceInfo() => info.machineId ?? info.id, + _ => 'Unknown', + }; + } + + Future computerName() async { + final info = await deviceInfo.deviceInfo; + + return switch (info) { + AndroidDeviceInfo() => info.model, + IosDeviceInfo() => info.localizedModel, + MacOsDeviceInfo() => info.computerName, + WindowsDeviceInfo() => info.computerName, + LinuxDeviceInfo() => info.name, + _ => 'Unknown', + }; + } +} diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index f4c279b4..95777f56 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -18,6 +18,11 @@ dependencies: - libjsoncpp25 - libmpv1 | libmpv2 - xdg-user-dirs + - avahi-daemon + - avahi-discover + - avahi-utils + - libnss-mdns + - mdns-scan essential: false icon: assets/spotube-logo.png diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml index 1f952d0e..12b4473e 100644 --- a/linux/packaging/rpm/make_config.yaml +++ b/linux/packaging/rpm/make_config.yaml @@ -13,6 +13,9 @@ requires: - libsecret - libnotify - xdg-user-dirs + - avahi + - mdns-scan + - nss-mdns display_name: Spotube diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a7965e14..a9f6650f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,8 +8,10 @@ import Foundation import app_links import audio_service import audio_session +import bonsoir_darwin import device_info_plus import file_selector_macos +import flutter_inappwebview_macos import flutter_secure_storage_macos import local_notifier import media_kit_libs_macos_audio @@ -28,8 +30,10 @@ 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")) + SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) diff --git a/macos/Podfile b/macos/Podfile index 049abe29..9ec46f8c 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 566e8196..317de385 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -5,10 +5,16 @@ PODS: - FlutterMacOS - audio_session (0.0.1): - FlutterMacOS + - bonsoir_darwin (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS + - flutter_inappwebview_macos (0.0.1): + - FlutterMacOS + - OrderedSet (~> 5.0) - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) @@ -22,6 +28,7 @@ PODS: - media_kit_native_event_loop (1.0.0): - FlutterMacOS - metadata_god (0.0.1) + - OrderedSet (5.0.0) - package_info_plus (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): @@ -50,8 +57,10 @@ DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) + - bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - local_notifier (from `Flutter/ephemeral/.symlinks/plugins/local_notifier/macos`) @@ -72,6 +81,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - FMDB + - OrderedSet EXTERNAL SOURCES: app_links: @@ -80,10 +90,14 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/audio_service/macos audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos + bonsoir_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_inappwebview_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_secure_storage_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos FlutterMacOS: @@ -121,8 +135,10 @@ SPEC CHECKSUMS: app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 + bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 + flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a @@ -130,6 +146,7 @@ SPEC CHECKSUMS: media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 + OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 @@ -141,6 +158,6 @@ SPEC CHECKSUMS: window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 -PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 +PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0 COCOAPODS: 1.15.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index a2dd74c4..bf5d70cf 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -436,7 +436,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; @@ -567,7 +567,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -592,7 +592,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; }; diff --git a/pubspec.lock b/pubspec.lock index bbf4faeb..47c1aba3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -177,6 +177,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + bonsoir: + dependency: "direct main" + description: + name: bonsoir + sha256: "9703ca3ce201c7ab6cd278ae5a530a125959687f59c2b97822f88a8db5bef106" + url: "https://pub.dev" + source: hosted + version: "5.1.9" + bonsoir_android: + dependency: transitive + description: + name: bonsoir_android + sha256: "19583ae34a5e5743fa2c16619e4ec699b35ae5e6cece59b99b1cf21c1b4ed618" + url: "https://pub.dev" + source: hosted + version: "5.1.4" + bonsoir_darwin: + dependency: transitive + description: + name: bonsoir_darwin + sha256: "985c4c38b4cbfa57ed5870e724a7e17aa080ee7f49d03b43e6d08781511505c6" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_linux: + dependency: transitive + description: + name: bonsoir_linux + sha256: "65554b20bc169c68c311eb31fab46ccdd8ee3d3dd89a2d57c338f4cbf6ceb00d" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_platform_interface: + dependency: transitive + description: + name: bonsoir_platform_interface + sha256: "4ee898bec0b5a63f04f82b06da9896ae8475f32a33b6fa395bea56399daeb9f0" + url: "https://pub.dev" + source: hosted + version: "5.1.2" + bonsoir_windows: + dependency: transitive + description: + name: bonsoir_windows + sha256: abbc90b73ac39e823b0c127da43b91d8906dcc530fc0cec4e169cf0d8c4404b1 + url: "https://pub.dev" + source: hosted + version: "5.1.4" boolean_selector: dependency: transitive description: @@ -478,10 +526,10 @@ packages: dependency: "direct main" description: name: dbus - sha256: "6f07cba3f7b3448d42d015bfd3d53fe12e5b36da2423f23838efc1d5fb31a263" + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" url: "https://pub.dev" source: hosted - version: "0.7.8" + version: "0.7.10" device_frame: dependency: transitive description: @@ -494,10 +542,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" + sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" url: "https://pub.dev" source: hosted - version: "9.0.3" + version: "9.1.2" device_info_plus_platform_interface: dependency: transitive description: @@ -786,10 +834,58 @@ packages: dependency: "direct main" description: name: flutter_inappwebview - sha256: f73505c792cf083d5566e1a94002311be497d984b5607f25be36d685cf6361cf + sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" url: "https://pub.dev" source: hosted - version: "5.7.2+3" + version: "6.0.0" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: d247f6ed417f1f8c364612fa05a2ecba7f775c8d0c044c1d3b9ee33a6515c421 + url: "https://pub.dev" + source: hosted + version: "1.0.13" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f + url: "https://pub.dev" + source: hosted + version: "1.0.13" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636 + url: "https://pub.dev" + source: hosted + version: "1.0.11" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07 + url: "https://pub.dev" + source: hosted + version: "1.0.8" flutter_keyboard_visibility: dependency: transitive description: @@ -1146,6 +1242,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + http_methods: + dependency: transitive + description: + name: http_methods + sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" + url: "https://pub.dev" + source: hosted + version: "1.1.1" http_multi_server: dependency: transitive description: @@ -1882,13 +1986,21 @@ packages: source: hosted version: "2.3.2" shelf: - dependency: transitive + dependency: "direct main" description: name: shelf sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 url: "https://pub.dev" source: hosted version: "1.4.1" + shelf_router: + dependency: "direct main" + description: + name: shelf_router + sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 + url: "https://pub.dev" + source: hosted + version: "1.1.4" shelf_static: dependency: transitive description: @@ -1898,7 +2010,7 @@ packages: source: hosted version: "1.1.2" shelf_web_socket: - dependency: transitive + dependency: "direct main" description: name: shelf_web_socket sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" @@ -2311,13 +2423,13 @@ packages: source: hosted version: "0.5.0" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel - sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.4" webdriver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ef8401bc..9f323a6f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: cupertino_icons: ^1.0.5 curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 - device_info_plus: ^9.0.3 + device_info_plus: ^9.1.2 device_preview: ^1.1.0 dio: ^5.4.1 disable_battery_optimization: ^1.1.0+1 @@ -43,7 +43,7 @@ dependencies: flutter_displaymode: ^0.6.0 flutter_feather_icons: ^2.0.0+1 flutter_hooks: ^0.20.0 - flutter_inappwebview: ^5.7.2+3 + flutter_inappwebview: ^6.0.0 flutter_localizations: sdk: flutter flutter_native_splash: ^2.3.10 @@ -123,6 +123,11 @@ dependencies: flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 spotify: ^0.13.3 + bonsoir: ^5.1.9 + shelf: ^1.4.1 + shelf_router: ^1.1.4 + shelf_web_socket: ^1.0.4 + web_socket_channel: ^2.4.4 dev_dependencies: build_runner: ^2.3.2 diff --git a/untranslated_messages.json b/untranslated_messages.json index 4275f461..be7d38f1 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,6 +1,203 @@ { + "ar": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "bn": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ca": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "de": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "es": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "fa": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "fr": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "hi": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "it": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ja": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ko": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ne": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "nl": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "pl": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "pt": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "ru": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "tr": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "uk": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + "vi": [ "friends", - "no_lyrics_available" + "no_lyrics_available", + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" + ], + + "zh": [ + "enable_connect", + "enable_connect_description", + "devices", + "select", + "connect_client_alert", + "this_device", + "remote" ] } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index fcf9927e..d8a9db29 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -23,6 +24,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("AppLinksPluginCApi")); + BonsoirWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi")); DartDiscordRpcPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DartDiscordRpcPlugin")); FileSelectorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 0fe6e076..90292744 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links + bonsoir_windows dart_discord_rpc file_selector_windows flutter_secure_storage_windows From c8dd8025ec96bd78ed77cae35f1429aa48c16fde Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 4 Apr 2024 22:33:01 +0600 Subject: [PATCH 028/261] fix: instance of Artist bug #1362 --- lib/provider/proxy_playlist/proxy_playlist_provider.dart | 2 +- lib/services/audio_services/audio_services.dart | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index aa63e3f3..b5bcdefe 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -242,7 +242,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } final nthFetchedTrack = switch (track.runtimeType) { - SourcedTrack => track as SourcedTrack, + SourcedTrack() => track as SourcedTrack, _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track), }; diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index facbcc4c..338427aa 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -2,6 +2,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; @@ -46,7 +47,7 @@ class AudioServices { id: track.id!, album: track.album?.name ?? "", title: track.name!, - artist: track.artists?.toString() ?? "", + artist: (track.artists)?.asString() ?? "", duration: track is SourcedTrack ? track.sourceInfo.duration : Duration(milliseconds: track.durationMs ?? 0), From 5afe823abdb198340b55d138d8173d886a811632 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Apr 2024 00:48:08 +0600 Subject: [PATCH 029/261] feat(lyrics): add LRCLIB lyrics provider as fallback --- lib/models/lyrics.dart | 14 ++ lib/pages/lyrics/lyrics.dart | 32 +++- lib/pages/lyrics/plain_lyrics.dart | 2 +- lib/pages/lyrics/synced_lyrics.dart | 199 ++++++++++++------------ lib/provider/spotify/lyrics/synced.dart | 117 ++++++++++++-- lib/provider/spotify/spotify.dart | 2 + lib/utils/service_utils.dart | 5 +- pubspec.lock | 8 + pubspec.yaml | 1 + 9 files changed, 270 insertions(+), 110 deletions(-) diff --git a/lib/models/lyrics.dart b/lib/models/lyrics.dart index c800b040..f6457287 100644 --- a/lib/models/lyrics.dart +++ b/lib/models/lyrics.dart @@ -1,13 +1,18 @@ +import 'package:lrc/lrc.dart'; + class SubtitleSimple { Uri uri; String name; List lyrics; int rating; + String provider; + SubtitleSimple({ required this.uri, required this.name, required this.lyrics, required this.rating, + required this.provider, }); factory SubtitleSimple.fromJson(Map json) { @@ -18,6 +23,7 @@ class SubtitleSimple { .map((e) => LyricSlice.fromJson(e as Map)) .toList(), rating: json["rating"] as int, + provider: json["provider"] as String? ?? "unknown", ); } @@ -27,6 +33,7 @@ class SubtitleSimple { "name": name, "lyrics": lyrics.map((e) => e.toJson()).toList(), "rating": rating, + "provider": provider, }; } } @@ -37,6 +44,13 @@ class LyricSlice { LyricSlice({required this.time, required this.text}); + factory LyricSlice.fromLrcLine(LrcLine line) { + return LyricSlice( + time: line.timestamp, + text: line.lyrics.trim(), + ); + } + factory LyricSlice.fromJson(Map json) { return LyricSlice( time: Duration(milliseconds: json["time"]), diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 6d406e33..a0db7178 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -2,6 +2,7 @@ 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:spotube/collections/spotube_icons.dart'; @@ -19,6 +20,7 @@ import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; class LyricsPage extends HookConsumerWidget { final bool isModal; @@ -43,13 +45,41 @@ class LyricsPage extends HookConsumerWidget { noSetBGColor: true, ); - final tabbar = ThemedButtonsTabBar( + PreferredSizeWidget tabbar = ThemedButtonsTabBar( tabs: [ Tab(text: " ${context.l10n.synced} "), Tab(text: " ${context.l10n.plain} "), ], ); + tabbar = PreferredSize( + preferredSize: tabbar.preferredSize, + child: Row( + children: [ + tabbar, + const Spacer(), + Consumer( + builder: (context, ref, child) { + final playback = ref.watch(ProxyPlaylistNotifier.provider); + final lyric = + ref.watch(syncedLyricsProvider(playback.activeTrack)); + final providerName = lyric.asData?.value.provider; + + if (providerName == null) { + return const SizedBox.shrink(); + } + + return Align( + alignment: Alignment.bottomRight, + child: Text("Powered by $providerName"), + ); + }, + ), + const Gap(5), + ], + ), + ); + final auth = ref.watch(AuthenticationNotifier.provider); if (auth == null) { diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index f1c6ec2e..2c0df0aa 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -4,7 +4,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; @@ -120,6 +119,7 @@ class PlainLyrics extends HookConsumerWidget { lyrics == null && playlist.activeTrack == null ? "No Track being played currently" : lyrics ?? "", + textAlign: TextAlign.center, ), ); }, diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 5e7a24c8..52824f5e 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,4 +1,8 @@ +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -71,125 +75,128 @@ class SyncedLyrics extends HookConsumerWidget { ); return Stack( children: [ - Column( - children: [ + CustomScrollView( + controller: controller, + slivers: [ if (isModal != true) - Center( - child: Text( + SliverAppBar( + automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + centerTitle: true, + title: Text( playlist.activeTrack?.name ?? "Not Playing", style: headlineTextStyle, ), - ), - if (isModal != true) - Center( - child: Text( - playlist.activeTrack?.artists?.asString() ?? "", - style: mediaQuery.mdAndUp - ? textTheme.headlineSmall - : textTheme.titleLarge, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(40), + child: Text( + playlist.activeTrack?.artists?.asString() ?? "", + style: mediaQuery.mdAndUp + ? textTheme.headlineSmall + : textTheme.titleLarge, + ), ), ), if (lyricValue != null && lyricValue.lyrics.isNotEmpty && lyricsState.asData?.value.static != true) - Expanded( - child: ListView.builder( - controller: controller, - itemCount: lyricValue.lyrics.length, - itemBuilder: (context, index) { - final lyricSlice = lyricValue.lyrics[index]; - final isActive = lyricSlice.time.inSeconds == currentTime; + SliverList.builder( + itemCount: lyricValue.lyrics.length, + itemBuilder: (context, index) { + final lyricSlice = lyricValue.lyrics[index]; + final isActive = lyricSlice.time.inSeconds == currentTime; - if (isActive) { - controller.scrollToIndex( - index, - preferPosition: AutoScrollPosition.middle, - ); - } - return AutoScrollTag( - key: ValueKey(index), - index: index, - controller: controller, - child: lyricSlice.text.isEmpty - ? Container( + if (isActive) { + controller.scrollToIndex( + index, + preferPosition: AutoScrollPosition.middle, + ); + } + return AutoScrollTag( + key: ValueKey(index), + index: index, + controller: controller, + child: lyricSlice.text.isEmpty + ? Container( + padding: index == lyricValue.lyrics.length - 1 + ? EdgeInsets.only( + bottom: mediaQuery.size.height / 2, + ) + : null, + ) + : Center( + child: Padding( padding: index == lyricValue.lyrics.length - 1 - ? EdgeInsets.only( - bottom: mediaQuery.size.height / 2, + ? const EdgeInsets.all(8.0).copyWith( + bottom: 100, ) - : null, - ) - : Center( - child: Padding( - padding: index == lyricValue.lyrics.length - 1 - ? const EdgeInsets.all(8.0).copyWith( - bottom: 100, - ) - : const EdgeInsets.all(8.0), - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 250), - style: TextStyle( - fontWeight: isActive - ? FontWeight.w500 - : FontWeight.normal, - fontSize: (isActive ? 28 : 26) * - (textZoomLevel.value / 100), - ), - textAlign: TextAlign.center, - child: InkWell( - onTap: () async { - final duration = - await audioPlayer.duration ?? - Duration.zero; - final time = Duration( - seconds: - lyricSlice.time.inSeconds - delay, - ); - if (time > duration || time.isNegative) { - return; - } - audioPlayer.seek(time); - }, - child: Builder(builder: (context) { - return StrokeText( - text: lyricSlice.text, - textStyle: - DefaultTextStyle.of(context).style, - textColor: isActive - ? Colors.white - : palette.bodyTextColor, - strokeColor: isActive - ? Colors.black - : Colors.transparent, - ); - }), - ), + : const EdgeInsets.all(8.0), + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 250), + style: TextStyle( + fontWeight: isActive + ? FontWeight.w500 + : FontWeight.normal, + fontSize: (isActive ? 28 : 26) * + (textZoomLevel.value / 100), + ), + textAlign: TextAlign.center, + child: InkWell( + onTap: () async { + final duration = + await audioPlayer.duration ?? + Duration.zero; + final time = Duration( + seconds: + lyricSlice.time.inSeconds - delay, + ); + if (time > duration || time.isNegative) { + return; + } + audioPlayer.seek(time); + }, + child: Builder(builder: (context) { + return StrokeText( + text: lyricSlice.text, + textStyle: + DefaultTextStyle.of(context).style, + textColor: isActive + ? Colors.white + : palette.bodyTextColor, + strokeColor: isActive + ? Colors.black + : Colors.transparent, + ); + }), ), ), ), - ); - }, - ), + ), + ); + }, ), if (playlist.activeTrack != null && (timedLyricsQuery.isLoading || timedLyricsQuery.isRefreshing)) - const Expanded( - child: ShimmerLyrics(), - ) + const SliverToBoxAdapter(child: ShimmerLyrics()) else if (playlist.activeTrack != null && (timedLyricsQuery.hasError)) ...[ - Container( - alignment: Alignment.center, - padding: const EdgeInsets.all(16), - child: Text( - context.l10n.no_lyrics_available, - style: bodyTextTheme, - textAlign: TextAlign.center, + SliverToBoxAdapter( + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(16), + child: Text( + context.l10n.no_lyrics_available, + style: bodyTextTheme, + textAlign: TextAlign.center, + ), ), ), - const Gap(26), - const Icon(SpotubeIcons.noLyrics, size: 60), + const SliverGap(26), + const SliverToBoxAdapter( + child: Icon(SpotubeIcons.noLyrics, size: 60), + ), ] else if (lyricsState.asData?.value.static == true) - Expanded( + SliverFillRemaining( child: Center( child: RichText( textAlign: TextAlign.center, diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index d86735db..6ce74ae7 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -6,26 +6,28 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier load(); } - @override - FutureOr build(track) async { - final spotify = ref.watch(spotifyProvider); - if (track == null) { - throw "No track currently"; - } - final token = await spotify.getCredentials(); + Track get _track => arg!; + + Future getSpotifyLyrics(String? token) async { final res = await http.get( Uri.parse( - "https://spclient.wg.spotify.com/color-lyrics/v2/track/${track.id}?format=json&market=from_token", + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", ), headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", "App-platform": "WebPlayer", - "authorization": "Bearer ${token.accessToken}" + "authorization": "Bearer $token" }); if (res.statusCode != 200) { - throw Exception("Unable to find lyrics"); + return SubtitleSimple( + lyrics: [], + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "Spotify", + ); } final linesRaw = Map.castFrom( jsonDecode(res.body), @@ -41,12 +43,105 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: lines, - name: track.name!, + name: _track.name!, uri: res.request!.url, rating: 100, + provider: "Spotify", ); } + /// Lyrics credits: [lrclib.net](https://lrclib.net) and their contributors + /// Thanks for their generous public API + Future getLRCLibLyrics() async { + final packageInfo = await PackageInfo.fromPlatform(); + + final res = await http.get( + Uri( + scheme: "https", + host: "lrclib.net", + path: "/api/get", + queryParameters: { + "artist_name": _track.artists?.first.name, + "track_name": _track.name, + "album_name": _track.album?.name, + "duration": _track.duration?.inSeconds.toString(), + }, + ), + headers: { + "User-Agent": + "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" + }, + ); + + if (res.statusCode != 200) { + return SubtitleSimple( + lyrics: [], + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "LRCLib", + ); + } + + final json = jsonDecode(res.body) as Map; + + final syncedLyricsRaw = json["syncedLyrics"] as String?; + final syncedLyrics = syncedLyricsRaw?.isNotEmpty == true + ? Lrc.parse(syncedLyricsRaw!) + .lyrics + .map(LyricSlice.fromLrcLine) + .toList() + : null; + + if (syncedLyrics?.isNotEmpty == true) { + return SubtitleSimple( + lyrics: syncedLyrics!, + name: _track.name!, + uri: res.request!.url, + rating: 100, + provider: "LRCLib", + ); + } + + final plainLyrics = (json["plainLyrics"] as String) + .split("\n") + .map((line) => LyricSlice(text: line, time: Duration.zero)) + .toList(); + + return SubtitleSimple( + lyrics: plainLyrics, + name: _track.name!, + uri: res.request!.url, + rating: 0, + provider: "LRCLib", + ); + } + + @override + FutureOr build(track) async { + try { + final spotify = ref.watch(spotifyProvider); + if (track == null) { + throw "No track currently"; + } + final token = await spotify.getCredentials(); + SubtitleSimple lyrics = await getSpotifyLyrics(token.accessToken); + + if (lyrics.lyrics.isEmpty) { + lyrics = await getLRCLibLyrics(); + } + + if (lyrics.lyrics.isEmpty) { + throw Exception("Unable to find lyrics"); + } + + return lyrics; + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + rethrow; + } + } + @override FutureOr fromJson(Map json) => SubtitleSimple.fromJson(json.castKeyDeep()); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index b152db65..816420f6 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -8,6 +8,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; +import 'package:lrc/lrc.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:spotify/spotify.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; // ignore: depend_on_referenced_packages, implementation_imports diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 60c77e59..88c52896 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -251,6 +251,7 @@ abstract class ServiceUtils { uri: subtitleUri, lyrics: lrcList, rating: rateSortedResults.first["points"] as int, + provider: "Rent An Adviser", ); return subtitle; @@ -307,7 +308,9 @@ abstract class ServiceUtils { case SortBy.duration: return a.durationMs?.compareTo(b.durationMs ?? 0) ?? 0; case SortBy.artist: - return a.artists?.first.name?.compareTo(b.artists?.first.name ?? "") ?? 0; + return a.artists?.first.name + ?.compareTo(b.artists?.first.name ?? "") ?? + 0; case SortBy.album: return a.album?.name?.compareTo(b.album?.name ?? "") ?? 0; default: diff --git a/pubspec.lock b/pubspec.lock index 47c1aba3..588aca13 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1455,6 +1455,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + lrc: + dependency: "direct main" + description: + name: lrc + sha256: "5100362b5c8e97f4d3f03ff87efeb40e73a6dd780eca2cbde9312e0d44b8e5ba" + url: "https://pub.dev" + source: hosted + version: "1.0.2" mailer: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9f323a6f..298631d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -128,6 +128,7 @@ dependencies: shelf_router: ^1.1.4 shelf_web_socket: ^1.0.4 web_socket_channel: ^2.4.4 + lrc: ^1.0.2 dev_dependencies: build_runner: ^2.3.2 From f26503990cf4c7c3f3083e58f056c65dafe5f008 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Apr 2024 12:39:54 +0600 Subject: [PATCH 030/261] cd: use brew to install setuptools --- .github/workflows/spotube-release-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index e05bf75d..5d918a03 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -327,7 +327,7 @@ jobs: - name: Package Macos App run: | - python3 -m pip install setuptools + brew install python-setuptools npm install -g appdmg mkdir -p build/${{ env.BUILD_VERSION }} appdmg appdmg.json build/Spotube-macos-universal.dmg From 0d080b77b72529c0be5ebc27ace1c52307511f73 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 7 Apr 2024 13:05:40 +0600 Subject: [PATCH 031/261] fix(playback): sponsor block skips and stutters in same position --- .vscode/settings.json | 1 + lib/collections/fake.dart | 2 +- lib/main.dart | 1 - .../proxy_playlist/player_listeners.dart | 132 +++++++++ .../proxy_playlist_provider.dart | 274 ++---------------- .../proxy_playlist/skip_segments.dart | 110 +++++++ .../audio_players_streams_mixin.dart | 2 + lib/services/sourced_track/sourced_track.dart | 2 + 8 files changed, 270 insertions(+), 254 deletions(-) create mode 100644 lib/provider/proxy_playlist/player_listeners.dart create mode 100644 lib/provider/proxy_playlist/skip_segments.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 0fedc544..462d33ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "Amoled", "Buildless", "danceability", "fuzzywuzzy", diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 8f5f9e8b..c5379ec6 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -6,7 +6,7 @@ abstract class FakeData { static final Image image = Image() ..height = 1 ..width = 1 - ..url = "url"; + ..url = "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg"; static final Followers followers = Followers() ..href = "text" diff --git a/lib/main.dart b/lib/main.dart index 2a2d8d18..8de524c7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,7 +24,6 @@ import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart new file mode 100644 index 00000000..9069f3e1 --- /dev/null +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -0,0 +1,132 @@ +// ignore_for_file: invalid_use_of_protected_member + +import 'dart:async'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/proxy_playlist/skip_segments.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/sourced_track/exceptions.dart'; + +extension ProxyPlaylistListeners on ProxyPlaylistNotifier { + StreamSubscription subscribeToSourceChanges() => + audioPlayer.activeSourceChangedStream.listen((event) { + try { + final newActiveTrack = mapSourcesToTracks([event]).firstOrNull; + + if (newActiveTrack == null || + newActiveTrack.id == state.activeTrack?.id) { + return; + } + + notificationService.addTrack(newActiveTrack); + discord.updatePresence(newActiveTrack); + state = state.copyWith( + active: state.tracks + .toList() + .indexWhere((element) => element.id == newActiveTrack.id), + ); + + updatePalette(); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + } + }); + + StreamSubscription subscribeToPercentCompletion() { + final isPreSearching = ObjectRef(false); + + return audioPlayer.percentCompletedStream(2).listen((event) async { + if (isPreSearching.value || + audioPlayer.currentSource == null || + audioPlayer.nextSource == null || + isPlayable(audioPlayer.nextSource!)) return; + + try { + isPreSearching.value = true; + + final track = await ensureSourcePlayable(audioPlayer.nextSource!); + + if (track != null) { + state = state.copyWith(tracks: mergeTracks([track], state.tracks)); + } + } catch (e, stackTrace) { + // Removing tracks that were not found to avoid queue interruption + if (e is TrackNotFoundError) { + final oldTrack = + mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; + await removeTrack(oldTrack!.id!); + } + Catcher2.reportCheckedError(e, stackTrace); + } finally { + isPreSearching.value = false; + } + }); + } + + StreamSubscription subscribeToShuffleChanges() { + return audioPlayer.shuffledStream.listen((event) { + try { + final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources); + + final newActiveIndex = newlyOrderedTracks.indexWhere( + (element) => element.id == state.activeTrack?.id, + ); + + if (newActiveIndex == -1) return; + + state = state.copyWith( + tracks: newlyOrderedTracks.toSet(), + active: newActiveIndex, + ); + } catch (e, stackTrace) { + Catcher2.reportCheckedError(e, stackTrace); + } + }); + } + + StreamSubscription subscribeToSkipSponsor() { + return audioPlayer.positionStream.listen((position) async { + final currentSegments = await ref.read(segmentProvider.future); + + if (currentSegments?.segments.isNotEmpty != true || + position < const Duration(seconds: 3)) return; + + for (final segment in currentSegments!.segments) { + final seconds = position.inSeconds; + + if (seconds < segment.start || seconds >= segment.end) continue; + + await audioPlayer.seek(Duration(seconds: segment.end + 1)); + } + }); + } + + StreamSubscription subscribeToScrobbleChanged() { + String? lastScrobbled; + return audioPlayer.positionStream.listen((position) { + try { + final uid = state.activeTrack is LocalTrack + ? (state.activeTrack as LocalTrack).path + : state.activeTrack?.id; + + if (state.activeTrack == null || + lastScrobbled == uid || + position.inSeconds < 30) { + return; + } + + scrobbler.scrobble(state.activeTrack!); + lastScrobbled = uid; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + } + }); + } + + StreamSubscription subscribeToPlayerError() { + return audioPlayer.errorStream.listen((event) {}); + } +} diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index b5bcdefe..438088de 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -1,24 +1,18 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:math'; -import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/logger.dart'; - -import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; +import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -26,34 +20,11 @@ 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/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'; -/// Things implemented: -/// * [x] Sponsor-Block skip -/// * [x] Prefetch next track as [SourcedTrack] on 80% of current track -/// * [x] Mixed Queue containing both [SourcedTrack] and [LocalTrack] -/// * [x] Modification of the Queue -/// * [x] Add track at the end -/// * [x] Add track at the beginning -/// * [x] Remove track -/// * [x] Reorder track -/// * [x] Caching and loading of cache of tracks -/// * [x] Shuffling -/// * [x] loop => playlist, track, none -/// * [x] Alternative Track Source -/// * [x] Blacklisting of tracks and artist -/// -/// Don'ts: -/// * It'll not have any proxy method for [SpotubeAudioPlayer] -/// * It'll not store any sort of player state e.g playing, paused, shuffled etc -/// * For that, use [SpotubeAudioPlayer] - class ProxyPlaylistNotifier extends PersistedStateNotifier with NextFetcher { final Ref ref; @@ -74,162 +45,21 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier static AlwaysAliveRefreshable get notifier => provider.notifier; + List _subscriptions = []; + ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") { - () async { - notificationService = await AudioServices.create(ref, this); + AudioServices.create(ref, this).then( + (value) => notificationService = value, + ); - // listeners state - final currentSegments = - // using source as unique id because alternative track source support - ObjectRef<({String source, List segments})?>(null); - final isPreSearching = ObjectRef(false); - final isFetchingSegments = ObjectRef(false); - - audioPlayer.activeSourceChangedStream.listen((newActiveSource) async { - try { - final newActiveTrack = - mapSourcesToTracks([newActiveSource]).firstOrNull; - - if (newActiveTrack == null || - newActiveTrack.id == state.activeTrack?.id) { - return; - } - - notificationService.addTrack(newActiveTrack); - discord.updatePresence(newActiveTrack); - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == newActiveTrack.id), - ); - - updatePalette(); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - audioPlayer.shuffledStream.listen((event) { - try { - final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources); - - final newActiveIndex = newlyOrderedTracks.indexWhere( - (element) => element.id == state.activeTrack?.id, - ); - - if (newActiveIndex == -1) return; - - state = state.copyWith( - tracks: newlyOrderedTracks.toSet(), - active: newActiveIndex, - ); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - listenTo2Percent(int percent) async { - if (isPreSearching.value || - audioPlayer.currentSource == null || - audioPlayer.nextSource == null || - isPlayable(audioPlayer.nextSource!)) return; - - try { - isPreSearching.value = true; - - final track = await ensureSourcePlayable(audioPlayer.nextSource!); - - if (track != null) { - state = state.copyWith(tracks: mergeTracks([track], state.tracks)); - } - } catch (e, stackTrace) { - // Removing tracks that were not found to avoid queue interruption - if (e is TrackNotFoundError) { - final oldTrack = - mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - await removeTrack(oldTrack!.id!); - } - Catcher2.reportCheckedError(e, stackTrace); - } finally { - isPreSearching.value = false; - } - } - - audioPlayer.percentCompletedStream(2).listen(listenTo2Percent); - - audioPlayer.positionStream.listen((position) async { - if (state.activeTrack == null || state.activeTrack is LocalTrack) { - isFetchingSegments.value = false; - return; - } - try { - final isNotYTMode = state.activeTrack is! YoutubeSourcedTrack && - (state.activeTrack is PipedSourcedTrack && - preferences.searchMode == SearchMode.youtubeMusic); - - if (isNotYTMode || !preferences.skipNonMusic) return; - - final isNotSameSegmentId = - currentSegments.value?.source != audioPlayer.currentSource; - - if (currentSegments.value == null || - (isNotSameSegmentId && !isFetchingSegments.value)) { - isFetchingSegments.value = true; - try { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: await getAndCacheSkipSegments( - (state.activeTrack as SourcedTrack).sourceInfo.id, - ), - ); - } catch (e) { - if (audioPlayer.currentSource != null) { - currentSegments.value = ( - source: audioPlayer.currentSource!, - segments: [], - ); - } - } finally { - isFetchingSegments.value = false; - } - } - - // skipping in first 2 second breaks stream - if (currentSegments.value == null || - currentSegments.value!.segments.isEmpty || - position < const Duration(seconds: 3)) return; - - for (final segment in currentSegments.value!.segments) { - if (position.inSeconds >= segment.start && - position.inSeconds < segment.end) { - await audioPlayer.seek(Duration(seconds: segment.end)); - } - } - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - String? lastScrobbled; - audioPlayer.positionStream.listen((position) { - try { - final uid = state.activeTrack is LocalTrack - ? (state.activeTrack as LocalTrack).path - : state.activeTrack?.id; - - if (state.activeTrack == null || - lastScrobbled == uid || - position.inSeconds < 30) { - return; - } - - scrobbler.scrobble(state.activeTrack!); - lastScrobbled = uid; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - } - }); - }(); + _subscriptions = [ + // These are subscription methods from player_listeners.dart + subscribeToSourceChanges(), + subscribeToPercentCompletion(), + subscribeToShuffleChanges(), + subscribeToSkipSponsor(), + subscribeToScrobbleChanged(), + ]; } Future ensureSourcePlayable(String source) async { @@ -283,8 +113,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier }); } - // TODO: Safely Remove playing tracks - Future removeTrack(String trackId) async { final track = state.tracks.firstWhereOrNull((element) => element.id == trackId); @@ -533,72 +361,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier }); } - Future> getAndCacheSkipSegments(String id) async { - if (!preferences.skipNonMusic || - (preferences.audioSource == AudioSource.piped && - preferences.searchMode == SearchMode.youtubeMusic)) return []; - - try { - final cached = await SkipSegment.box.get(id); - if (cached != null && cached.isNotEmpty) { - return List.castFrom( - (cached as List) - .map( - (json) => SkipSegment.fromJson( - Map.castFrom(json), - ), - ) - .toList(), - ); - } - - final res = await get(Uri( - scheme: "https", - host: "sponsor.ajay.app", - path: "/api/skipSegments", - queryParameters: { - "videoID": id, - "category": [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'music_offtopic' - ], - "actionType": 'skip' - }, - )); - - if (res.body == "Not Found") { - return List.castFrom([]); - } - - final data = jsonDecode(res.body) as List; - final segments = data.map((obj) { - final start = obj["segment"].first.toInt(); - final end = obj["segment"].last.toInt(); - return SkipSegment( - start, - end, - ); - }).toList(); - getLogger('getSkipSegments').t( - "[SponsorBlock] successfully fetched skip segments for $id", - ); - - await SkipSegment.box.put( - id, - segments.map((e) => e.toJson()).toList(), - ); - return List.castFrom(segments); - } catch (e, stack) { - await SkipSegment.box.put(id, []); - Catcher2.reportCheckedError(e, stack); - return List.castFrom([]); - } - } - @override set state(state) { super.state = state; @@ -631,4 +393,12 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier final json = state.toJson(); return json; } + + @override + void dispose() { + for (final subscription in _subscriptions) { + subscription.cancel(); + } + super.dispose(); + } } diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart new file mode 100644 index 00000000..94a63324 --- /dev/null +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/models/skip_segment.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; + +class SourcedSegments { + final String source; + final List segments; + + SourcedSegments({required this.source, required this.segments}); +} + +Future> getAndCacheSkipSegments(String id) async { + try { + final cached = await SkipSegment.box.get(id) as List?; + if (cached != null && cached.isNotEmpty) { + return List.castFrom( + cached + .map( + (json) => SkipSegment.fromJson( + Map.castFrom(json), + ), + ) + .toList(), + ); + } + + final res = await get(Uri( + scheme: "https", + host: "sponsor.ajay.app", + path: "/api/skipSegments", + queryParameters: { + "videoID": id, + "category": [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'music_offtopic' + ], + "actionType": 'skip' + }, + )); + + if (res.body == "Not Found") { + return List.castFrom([]); + } + + final data = jsonDecode(res.body) as List; + final segments = data.map((obj) { + final start = obj["segment"].first.toInt(); + final end = obj["segment"].last.toInt(); + return SkipSegment(start, end); + }).toList(); + + await SkipSegment.box.put( + id, + segments.map((e) => e.toJson()).toList(), + ); + return List.castFrom(segments); + } catch (e, stack) { + await SkipSegment.box.put(id, []); + Catcher2.reportCheckedError(e, stack); + return List.castFrom([]); + } +} + +final segmentProvider = FutureProvider( + (ref) async { + final track = ref.watch( + ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), + ); + if (track == null) return null; + + if (track is LocalTrack || track is! SourcedTrack) return null; + + final skipNonMusic = ref.watch( + userPreferencesProvider.select( + (s) { + final isPipedYTMusicMode = s.audioSource == AudioSource.piped && + s.searchMode == SearchMode.youtubeMusic; + + return s.skipNonMusic && !isPipedYTMusicMode; + }, + ), + ); + + if (!skipNonMusic) { + return SourcedSegments( + segments: [], + source: track.sourceInfo.id, + ); + } + + final segments = await getAndCacheSkipSegments(track.sourceInfo.id); + + return SourcedSegments( + source: track.sourceInfo.id, + segments: segments, + ); + }, +); diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index f05ba5ef..54e36c6b 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -146,4 +146,6 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { Stream get selectedDeviceStream => _mkPlayer.stream.audioDevice.asBroadcastStream(); + + Stream get errorStream => _mkPlayer.stream.error; } diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index c06efd87..a5e094ed 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -131,6 +131,8 @@ abstract class SourcedTrack extends Track { }; } on HttpClientClosedException catch (_) { return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); + } on VideoUnplayableException catch (_) { + return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); } catch (e) { if (e is DioException || e is ClientException || e is SocketException) { if (preferences.audioSource == AudioSource.jiosaavn) { From de68fe3a6b0ade67a6e91a68de896ad634c491b6 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Apr 2024 01:15:39 +0600 Subject: [PATCH 032/261] chore: make dropdown buttons more attractive --- .../shared/adaptive/adaptive_select_tile.dart | 22 ++++++++++++++----- lib/pages/settings/sections/desktop.dart | 2 ++ .../settings/sections/language_region.dart | 2 ++ lib/pages/settings/sections/playback.dart | 11 +++++++--- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/lib/components/shared/adaptive/adaptive_select_tile.dart b/lib/components/shared/adaptive/adaptive_select_tile.dart index 58666e46..3f6d2700 100644 --- a/lib/components/shared/adaptive/adaptive_select_tile.dart +++ b/lib/components/shared/adaptive/adaptive_select_tile.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/constrains.dart'; class AdaptiveSelectTile extends HookWidget { @@ -38,11 +39,22 @@ class AdaptiveSelectTile extends HookWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final rawControl = DropdownButton( - items: options, - value: value, - onChanged: onChanged, - menuMaxHeight: mediaQuery.size.height * 0.6, + final rawControl = DecoratedBox( + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: DropdownButton( + items: options, + value: value, + onChanged: onChanged, + menuMaxHeight: mediaQuery.size.height * 0.6, + underline: const SizedBox.shrink(), + padding: const EdgeInsets.symmetric(horizontal: 10), + borderRadius: BorderRadius.circular(10), + icon: const Icon(SpotubeIcons.angleDown), + dropdownColor: theme.colorScheme.secondaryContainer, + ), ); final controlPlaceholder = useMemoized( () => options diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index 2c0a1466..4e4408d9 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:gap/gap.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'; @@ -19,6 +20,7 @@ class SettingsDesktopSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.desktop, children: [ + const Gap(10), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.close), title: Text(context.l10n.close_behavior), diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart index fbfe1030..76670c77 100644 --- a/lib/pages/settings/sections/language_region.dart +++ b/lib/pages/settings/sections/language_region.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/language_codes.dart'; @@ -23,6 +24,7 @@ class SettingsLanguageRegionSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.language_region, children: [ + const Gap(10), AdaptiveSelectTile( value: preferences.locale, onChanged: (locale) { diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index e023cc60..eeae98cb 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -25,6 +26,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.playback, children: [ + const Gap(10), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.audioQuality), title: Text(context.l10n.audio_quality), @@ -49,6 +51,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { } }, ), + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.api), title: Text(context.l10n.audio_source), @@ -181,7 +184,8 @@ class SettingsPlaybackSection extends HookConsumerWidget { value: preferences.normalizeAudio, onChanged: preferencesNotifier.setNormalizeAudio, ), - if (preferences.audioSource != AudioSource.jiosaavn) + if (preferences.audioSource != AudioSource.jiosaavn) ...[ + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.stream), title: Text(context.l10n.streaming_music_codec), @@ -201,7 +205,7 @@ class SettingsPlaybackSection extends HookConsumerWidget { preferencesNotifier.setStreamMusicCodec(value); }, ), - if (preferences.audioSource != AudioSource.jiosaavn) + const Gap(5), AdaptiveSelectTile( secondary: const Icon(SpotubeIcons.file), title: Text(context.l10n.download_music_codec), @@ -220,7 +224,8 @@ class SettingsPlaybackSection extends HookConsumerWidget { if (value == null) return; preferencesNotifier.setDownloadMusicCodec(value); }, - ), + ) + ], SwitchListTile( secondary: const Icon(SpotubeIcons.repeat), title: Text(context.l10n.endless_playback), From b70f250e8d5137fd990787ec9e3d058126cf14f3 Mon Sep 17 00:00:00 2001 From: watchakorn-18k <74919942+watchakorn-18k@users.noreply.github.com> Date: Tue, 9 Apr 2024 22:47:02 +0700 Subject: [PATCH 033/261] feat(translations): add Thai Language (#1319) * feat : added Thai Language * docs: broken link in README.md (fixes #1310) (#1311) --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho --- README.md | 2 +- lib/collections/language_codes.dart | 8 +- lib/l10n/app_th.arb | 317 ++++++++++++++++++++++++++++ lib/l10n/l10n.dart | 3 +- 4 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 lib/l10n/app_th.arb diff --git a/README.md b/README.md index de00054f..4ad4e1be 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ eliminating the need for Spotify Premium Btw it's not just another Electron app 😉 -Visit the website +Visit the website Discord Server Support me on Patron diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index 4b7a3a90..bd3f8740 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -637,10 +637,10 @@ abstract class LanguageLocals { // name: "Tajik", // nativeName: "тоҷикӣ, toğikī, تاجیکی‎", // ), - // "th": const ISOLanguageName( - // name: "Thai", - // nativeName: "ไทย", - // ), + "th": const ISOLanguageName( + name: "Thai", + nativeName: "ไทย", + ), // "ti": const ISOLanguageName( // name: "Tigrinya", // nativeName: "ትግርኛ", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb new file mode 100644 index 00000000..94d144e9 --- /dev/null +++ b/lib/l10n/app_th.arb @@ -0,0 +1,317 @@ +{ + "guest": "ผู้มาเยือน", + "browse": "เรียกดู", + "search": "ค้นหา", + "library": "คลัง", + "lyrics": "เนื้อเพลง", + "settings": "ตั้งค่า", + "genre_categories_filter": "กรองประเภทหรือแนวเพลง...", + "genre": "ประเภท", + "personalized": "ปรับแต่ง", + "featured": "เด่น", + "new_releases": "เพิ่งปล่อยใหม่", + "songs": "เพลง", + "playing_track": "กำลังเล่น {track}", + "queue_clear_alert": "การดำเนินการนี้จะล้างคิวปัจจุบัน {track length} แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?", + "load_more": "โหลดเพิ่มเติม", + "playlists": "เพลย์ลิสต์", + "artists": "ศิลปิน", + "albums": "อัลบั้ม", + "tracks": "แทร็ก", + "downloads": "ดาวน์โหลด" + "filter_playlists": "กรองเพลย์ลิสต์...", + "liked_tracks": "เพลงที่ชอบ", + "liked_tracks_description": "เพลงที่คุณชื่นชอบทั้งหมด", + "create_playlist": "สร้างเพลย์ลิสต์", + "create_a_playlist": "สร้างเพลย์ลิสต์", + "update_playlist": "อัพเดทเพลย์ลิสต์", + "create": "สร้าง", + "cancel": "ยกเลิก", + "update": "อัพเดท", + "playlist_name": "ชื่อเพลย์ลิสต์", + "name_of_playlist": "ชื่อของเพลย์ลิสต์", + "description": "คำอธิบาย", + "public": "สาธารณะ", + "collaborative": "ร่วมมือกัน", + "search_local_tracks": "ค้นหาเพลงในเครื่อง...", + "play": "เล่น", + "delete": "ลบ", + "none": "ไม่มี" + "sort_a_z": "เรียงตาม A-Z", + "sort_z_a": "เรียงตาม Z-A", + "sort_artist": "เรียงตามศิลปิน", + "sort_album": "เรียงตามอัลบั้ม", + "sort_duration": "เรียงตามความยาว", + "sort_tracks": "เรียงตามเพลง", + "currently_downloading": "กำลังดาวน์โหลด ({tracks_length})", + "cancel_all": "ยกเลิกทั้งหมด", + "filter_artist": "กรองศิลปิน...", + "followers": "{followers} ผู้ติดตาม", + "add_artist_to_blacklist": "เพิ่มศิลปินในบัญชีดำ", + "top_tracks": "เพลงฮิต", + "fans_also_like": "แฟนๆ ยังชอบ", + "loading": "กำลังโหลด...", + "artist": "ศิลปิน", + "blacklisted": "อยู่ในบัญชีดำ", + "following": "กำลังติดตาม", + "follow": "ติดตาม", + "artist_url_copied": "คัดลอก URL ศิลปินไปยังคลิปบอร์ด", + "added_to_queue": "เพิ่ม {tracks} เพลงลงในคิว", + "filter_albums": "กรองอัลบั้ม...", + "synced": "ซิงค์", + "plain": "เรียบง่าย", + "shuffle": "สุ่ม", + "search_tracks": "ค้นหาเพลง...", + "released": "เผยแพร่", + "error": "ข้อผิดพลาด {error}", + "title": "ชื่อ", + "time": "เวลา" + "more_actions": "เพิ่มเติม", + "download_count": "ดาวน์โหลด ({count})", + "add_count_to_playlist": "เพิ่ม ({count}) ลงในเพลย์ลิสต์", + "add_count_to_queue": "เพิ่ม ({count}) ลงในคิว", + "play_count_next": "เล่น ({count}) ต่อไป", + "album": "อัลบั้ม", + "copied_to_clipboard": "คัดลอก {data} ไปยังคลิปบอร์ด", + "add_to_following_playlists": "เพิ่ม {track} ลงในเพลย์ลิสต์", + "add": "เพิ่ม", + "added_track_to_queue": "เพิ่ม {track} ลงในคิว", + "add_to_queue": "เพิ่มลงในคิว", + "track_will_play_next": "{track} จะเล่นต่อไป", + "play_next": "เล่นต่อไป", + "removed_track_from_queue": "ลบ {track} ออกจากคิว", + "remove_from_queue": "ลบออกจากคิว", + "remove_from_favorites": "ลบออกจากรายการโปรด", + "save_as_favorite": "บันทึกเป็นรายการโปรด", + "add_to_playlist": "เพิ่มลงในเพลย์ลิสต์", + "remove_from_playlist": "ลบออกจากเพลย์ลิสต์", + "add_to_blacklist": "เพิ่มลงในบัญชีดำ", + "remove_from_blacklist": "ลบออกจากบัญชีดำ", + "share": "แชร์", + "mini_player": "มินิเพลเยอร์", + "slide_to_seek": "เลื่อนเพื่อไปข้างหน้าหรือถอยหลัง", + "shuffle_playlist": "สุ่มเพลย์ลิสต์", + "unshuffle_playlist": "ยกเลิกการสุ่มเพลย์ลิสต์", + "previous_track": "แทร็กก่อนหน้า", + "next_track": "แทร็กถัดไป", + "pause_playback": "หยุดการเล่น", + "resume_playback": "เล่นต่อ", + "loop_track": "วนเพลง", + "repeat_playlist": "ซ้ำเพลย์ลิสต์", + "queue": "คิว", + "alternative_track_sources": "แหล่งแทร็กอื่น", + "download_track": "ดาวน์โหลดแทร็ก", + "tracks_in_queue": "{tracks} แทร็กในคิว" + "clear_all": "ล้างทั้งหมด", + "show_hide_ui_on_hover": "แสดง/ซ่อน UI เมื่อโฮเวอร์", + "always_on_top": "อยู่ด้านบนเสมอ", + "exit_mini_player": "ออกจากมินิเพลย์เยอร์", + "download_location": "ตำแหน่งดาวน์โหลด", + "account": "บัญชี", + "login_with_spotify": "เข้าสู่ระบบด้วยบัญชี Spotify", + "connect_with_spotify": "เชื่อมต่อกับ Spotify", + "logout": "ออกจากระบบ", + "logout_of_this_account": "ออกจากระบบบัญชีนี้", + "language_region": "ภาษาและภูมิภาค", + "language": "ภาษา", + "system_default": "ค่าเริ่มต้นของระบบ", + "market_place_region": "ภูมิภาค Marketplace", + "recommendation_country": "ประเทศที่แนะนำ", + "appearance": "ลักษณะที่ปรากฏ", + "layout_mode": "โหมดเค้าโครง", + "override_layout_settings": "แทนที่การตั้งค่าโหมดเค้าโครงแบบตอบสนอง", + "adaptive": "ปรับเปลี่ยน", + "compact": "กระชับ", + "extended": "ขยาย", + "theme": "ธีม", + "dark": "มืด", + "light": "สว่าง", + "system": "ระบบ", + "accent_color": "สีเน้น", + "sync_album_color": "ซิงค์สีอัลบั้ม", + "sync_album_color_description": "ใช้สีเด่นของอาร์ตอัลบั้มเป็นสีเน้น", + "playback": "การเล่น", + "audio_quality": "คุณภาพเสียง", + "high": "สูง", + "low": "ต่ำ", + "pre_download_play": "ดาวน์โหลดล่วงหน้าและเล่น", + "pre_download_play_description": "แทนที่จะสตรีมเสียง ดาวน์โหลดข้อมูลและเล่นแทน (แนะนำสำหรับผู้ใช้แบนด์วิดธ์สูง)", + "skip_non_music": "ข้ามส่วนที่ไม่ใช่เพลง (SponsorBlock)", + "blacklist_description": "แทร็กและศิลปินที่บล็อก", + "wait_for_download_to_finish": "โปรดรอให้การดาวน์โหลดปัจจุบันเสร็จสิ้น", + "desktop": "เดสก์ท็อป", + "close_behavior": "ปิดพฤติกรรม", + "close": "ปิด", + "minimize_to_tray": "ลดขนาดลงถาด", + "show_tray_icon": "แสดงไอคอนถาดระบบ", + "about": "เกี่ยวกับ", + "u_love_spotube": "เรารู้ว่าคุณรัก Spotube", + "check_for_updates": "ตรวจสอบการปรับปรุง", + "about_spotube": "เกี่ยวกับ Spotube", + "blacklist": "แบล็กลิสต์", + "please_sponsor": "กรุณาสนับสนุน/บริจาค", + "spotube_description": "Spotube โปรแกรมเล่น Spotify ฟรีสำหรับทุกคน น้ำหนักเบา รองรับหลายแพลตฟอร์ม", + "version": "รุ่น", + "build_number": "หมายเลขบิลด์", + "founder": "ผู้ก่อตั้ง", + "repository": "ที่เก็บ", + "bug_issues": "ข้อผิดพลาด+ปัญหา", + "made_with": "ทำด้วย❤️ใน บังคลาเทศ🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "ใบอนุญาต", + "add_spotify_credentials": "เพิ่มข้อมูลรับรอง Spotify ของคุณเพื่อเริ่มต้น", + "credentials_will_not_be_shared_disclaimer": "ไม่ต้องกังวล ข้อมูลรับรองใดๆ ของคุณจะไม่ถูกเก็บรวบรวมหรือแชร์กับใคร", + "know_how_to_login": "ไม่รู้จักวิธีดำเนินการนี้ใช่ไหม", + "follow_step_by_step_guide": "ทำตามคู่มือทีละขั้น", + "spotify_cookie": "คุกกี้ Spotify {name}", + "cookie_name_cookie": "คุกกี้ {name}", + "fill_in_all_fields": "กรุณากรอกข้อมูลทุกช่อง", + "submit": "ยื่น", + "exit": "ออก", + "previous": "ย้อนกลับ", + "next": "ถัดไป", + "done": "เสร็จ", + "step_1": "ขั้นที่ 1", + "first_go_to": "ก่อนอื่น ไปที่", + "login_if_not_logged_in": "ยังไม่ได้เข้าสู่ระบบ ให้เข้าสู่ระบบ/ลงทะเบียน", + "step_2": "ขั้นที่ 2", + "step_2_steps": "1. หลังจากเข้าสู่ระบบแล้ว กด F12 หรือ คลิกขวาที่เมาส์ > ตรวจสอบเพื่อเปิด Devtools เบราว์เซอร์\n2. จากนั้นไปที่แท็บ "แอปพลิเคชัน" (Chrome, Edge, Brave เป็นต้น) หรือแท็บ "ที่เก็บข้อมูล" (Firefox, Palemoon เป็นต้น)\n3. ไปที่ส่วน "คุกกี้" แล้วไปที่ subsection "https://accounts.spotify.com"", + "step_3": "ขั้นที่ 3", + "step_3_steps": "คัดลอกค่าคุกกี้ "sp_dc"", + "success_emoji": "สำเร็จ", + "success_message": "ตอนนี้คุณเข้าสู่ระบบด้วยบัญชี Spotify ของคุณเรียบร้อยแล้ว ยอดเยี่ยม!", + "step_4": "ขั้นที่ 4", + "step_4_steps": "วางค่า "sp_dc" ที่คัดลอกมา", + "something_went_wrong": "มีอะไรผิดพลาด", + "piped_instance": "อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe", + "piped_description": "อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe ที่ใช้สำหรับการจับคู่แทร็ก", + "piped_warning": "บางอย่างอาจใช้งานไม่ได้ผล คุณจึงต้องรับความเสี่ยงเอง", + "generate_playlist": "สร้างเพลย์ลิสต์", + "track_exists": "แทร็ก {track} มีอยู่แล้ว", + "replace_downloaded_tracks": "แทนที่แทร็กที่ดาวน์โหลดทั้งหมด", + "skip_download_tracks": "ข้ามการดาวน์โหลดแทร็กที่ดาวน์โหลดทั้งหมด", + "do_you_want_to_replace": "คุณต้องการแทนที่แทร็กที่มีอยู่หรือไม่", + "replace": "แทนที่", + "skip": "ข้าม", + "select_up_to_count_type": "เลือกสูงสุด {count} {type}", + "select_genres": "เลือกประเภท", + "add_genres": "เพิ่มประเภท", + "country": "ประเทศ", + "number_of_tracks_generate": "จำนวนแทร็กที่จะสร้าง", + "acousticness": "อะคูสติก", + "danceability": "ความสามารถในการเต้น", + "energy": "พลัง", + "instrumentalness": "บรรเลง", + "liveness": "ความสด", + "loudness": "ความดัง", + "speechiness": "การพูด", + "valence": "ความสุข", + "popularity": "ความนิยม", + "key": "คีย์", + "duration": "ระยะเวลา (วินาที)", + "tempo": "ความเร็ว (BPM)", + "mode": "โหมด", + "time_signature": "ลายเซ็นเวลา", + "short": "สั้น", + "medium": "กลาง", + "long": "ยาว", + "min": "ต่ำสุด", + "max": "สูงสุด", + "target": "เป้าหมาย", + "moderate": "ปานกลาง", + "deselect_all": "ยกเลิกการเลือกทั้งหมด", + "select_all": "เลือกทั้งหมด", + "are_you_sure": "คุณแน่ใจไหม?", + "generating_playlist": "กำลังสร้างเพลย์ลิสต์ที่คุณกำหนดเอง...", + "selected_count_tracks": "เลือก {count} แทร็ก", + "download_warning": "ถ้าคุณดาวน์โหลดเพลงทั้งหมดเป็นจำนวนมาก คุณกำลังละเมิดลิขสิทธิ์เพลงและสร้างความเสียหายให้กับสังคมดนตรี สร้างสรรค์ หวังว่าคุณจะรับรู้เรื่องนี้ เสมอ พยายามเคารพและสนับสนุนผลงานหนักของศิลปิน", + "download_ip_ban_warning": "นอกเหนือจากนั้น IP ของคุณอาจถูกบล็อกบน YouTube เนื่องจากคำขอดาวน์โหลดมากเกินกว่าปกติ การบล็อก IP หมายความว่าคุณไม่สามารถใช้ YouTube (แม้ว่าคุณจะล็อกอินอยู่) เป็นเวลาอย่างน้อย 2-3 เดือนจากอุปกรณ์ IP นั้น และ Spotube จะไม่รับผิดชอบใด ๆ หากสิ่งนี้เกิดขึ้น", + "by_clicking_accept_terms": "คลิก 'ยอมรับ' คุณยินยอมตามเงื่อนไขต่อไปนี้:", + "download_agreement_1": "ฉันรู้ว่าฉันกำลังละเมิดลิขสิทธิ์เพลง ฉันเลว", + "download_agreement_2": "ฉันจะสนับสนุนศิลปินทุกที่ที่ฉันทำได้และฉันทำสิ่งนี้เพียงเพราะฉันไม่มีเงินซื้อผลงานศิลปะของพวกเขา", + "download_agreement_3": "ฉันรับทราบอย่างสมบูรณ์ว่า IP ของฉันอาจถูกบล็อกบน YouTube และฉันจะไม่ถือ Spotube หรือเจ้าของ/ผู้มีส่วนร่วมใด ๆ รับผิดชอบต่ออุบัติเหตุใด ๆ ที่เกิดจากการกระทำปัจจุบันของฉัน", + "decline": "ปฏิเสธ", + "accept": "ยอมรับ", + "details": "รายละเอียด", + "youtube": "youtube", + "channel": "ช่อง", + "likes": "ถูกใจ", + "dislikes": "ไม่ชอบ", + "views": "วิว", + "streamUrl": "สตรีม URL", + "stop": "หยุด", + "sort_newest": "เรียงตามการเพิ่มใหม่ล่าสุด", + "sort_oldest": "เรียงตามการเพิ่มเก่าสุด", + "sleep_timer": "ตั้งเวลาปิด", + "mins": "{minutes} นาที", + "hours": "{hours} ชั่วโมง", + "hour": "{hours} ชั่วโมง", + "custom_hours": "ชั่วโมงที่กำหนดเอง", + "logs": "บันทึก", + "developers": "นักพัฒนา", + "not_logged_in": "คุณไม่ได้เข้าสู่ระบบ", + "search_mode": "โหมดการค้นหา", + "audio_source": "แหล่งที่มาของเสียง", + "ok": "ตกลง", + "failed_to_encrypt": "เข้ารหัสล้มเหลว", + "encryption_failed_warning": "Spotube ใช้การเข้ารหัสเพื่อเก็บข้อมูลของคุณอย่างปลอดภัย แต่ไม่สามารถทำได้ ดังนั้นจะเปลี่ยนเป็นการจัดเก็บที่ไม่ปลอดภัย\nหากคุณใช้ Linux โปรดตรวจสอบว่าคุณได้ติดตั้งบริการลับ (gnome-keyring, kde-wallet, keepassxc เป็นต้น)", + "querying_info": "กำลังดึงข้อมูล...", + "piped_api_down": "Piped API ไม่ทำงาน", + "piped_down_error_instructions": "Piped instance {pipedInstance} ไม่ทำงานขณะนี้\n\nเปลี่ยนอินสแตนซ์หรือเปลี่ยน 'ประเภท API' เป็น YouTube API อย่างเป็นทางการ\n\nอย่าลืมรีสตาร์ทแอปหลังจากเปลี่ยน", + "you_are_offline": "คุณออฟไลน์อยู่", + "connection_restored": "การเชื่อมต่ออินเทอร์เน็ตของคุณได้รับการกู้คืน", + "use_system_title_bar": "ใช้แถบชื่อระบบ", + "crunching_results": "กำลังประมวลผล...", + "search_to_get_results": "ค้นหาเพื่อดูผลลัพธ์", + "use_amoled_mode": "ธีมมืดสนิท", + "pitch_dark_theme": "โหมด AMOLED", + "normalize_audio": "ปรับระดับเสียง", + "change_cover": "เปลี่ยนปก", + "add_cover": "เพิ่มปก", + "restore_defaults": "คืนค่าเริ่มต้น", + "download_music_codec": "ดาวน์โหลดโคเดคเพลง", + "streaming_music_codec": "สตรีมมิ่งโคเดคเพลง", + "login_with_lastfm": "เข้าสู่ระบบด้วย Last.fm", + "connect": "เชื่อมต่อ", + "disconnect_lastfm": "ตัดการเชื่อมต่อ Last.fm", + "disconnect": "ตัดการเชื่อมต่อ", + "username": "ชื่อผู้ใช้", + "password": "รหัสผ่าน", + "login": "เข้าสู่ระบบ", + "login_with_your_lastfm": "เข้าสู่ระบบด้วย Last.fm", + "scrobble_to_lastfm": "Scrobble ไปเป็น Last.fm", + "go_to_album": "ไปที่อัลบั้ม", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "เรียกดูทั้งหมด", + "genres": "ประเภท", + "explore_genres": "สำรวจประเภท", + "friends": "เพื่อน", + "no_lyrics_available": "ขออภัย ไม่พบเนื้อเพลงสำหรับเพลงนี้", + "start_a_radio": "เปิดวิทยุ", + "how_to_start_radio": "หากต้องการเปิดวิทยุฟังยังไง?", + "replace_queue_question": "คุณต้องการแทนที่คิวปัจจุบันหรือเพิ่มเข้าไปหรือไม่", + "endless_playback": "เล่นซ้ำ", + "delete_playlist": "ลบเพลย์ลิสต์", + "delete_playlist_confirmation": "คุณแน่ใจที่จะลบเพลย์ลิสต์นี้หรือไม่", + "local_tracks": "เพลงในเครื่อง", + "song_link": "ลิงค์เพลง", + "skip_this_nonsense": "ข้ามสิ่งไร้สาระนี้", + "freedom_of_music": "“เสรีภาพแห่งเสียงเพลง”", + "freedom_of_music_palm": "“เสรีภาพแห่งเสียงเพลง ในมือของคุณ”", + "get_started": "เริ่มต้น", + "youtube_source_description": "แนะนำและใช้งานได้ดีที่สุด", + "piped_source_description": "รู้สึกอิสระ? เหมือน YouTube แต่ฟรีกว่าเยอะ", + "jiosaavn_source_description": "ดีที่สุดสำหรับภูมิภาคเอเชียใต้", + "highest_quality": "คุณภาพสูงสุด: {quality}", + "select_audio_source": "เลือกแหล่งเสียง", + "endless_playback_description": "เพิ่มเพลงใหม่ลงในคิวโดยอัตโนมัติ", + "choose_your_region": "เลือกภูมิภาคของคุณ", + "choose_your_region_description": "สิ่งนี้จะช่วยให้ Spotube แสดงเนื้อหาที่เหมาะสมสำหรับคุณ", + "choose_ your_language": "เลือกภาษาของคุณ", + "help_project_grow": "ช่วยให้โครงการนี้เติบโต", + "help_project_grow_description": "Spotube เป็นโครงการโอเพนซอร์ส คุณสามารถช่วยให้โครงการนี้เติบโตได้โดยการมีส่วนร่วมในโครงการ รายงานข้อบกพร่อง หรือเสนอคุณสมบัติใหม่", + "contribute_on_github": "มีส่วนร่วมบน GitHub", + "donate_on_open_collective": "บริจาคบน Open Collective", + "browse_anonymously": "เรียกดูแบบไม่ระบุตัวตน" +} \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 31eecc99..4ba9254e 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -11,7 +11,7 @@ /// Stephan-P@github, SecularSteve@github => Dutch /// doannc2212@github => Vietnamese /// sappho192@github => Korean -library; +/// watchakorn-18k@github => Thai import 'package:flutter/material.dart'; class L10n { @@ -34,6 +34,7 @@ class L10n { const Locale('pt', 'PT'), const Locale('ru', 'RU'), const Locale('uk', 'UA'), + const Locale('th', 'TH'), const Locale('tr', 'TR'), const Locale('zh', 'CN'), const Locale('vi', 'VN'), From 9391e7a3793003e5674f844c8d522b5b0be87f2a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Apr 2024 22:04:37 +0600 Subject: [PATCH 034/261] chore: thai translation error fix errors --- lib/l10n/app_th.arb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 94d144e9..5df6bc20 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -18,7 +18,7 @@ "artists": "ศิลปิน", "albums": "อัลบั้ม", "tracks": "แทร็ก", - "downloads": "ดาวน์โหลด" + "downloads": "ดาวน์โหลด", "filter_playlists": "กรองเพลย์ลิสต์...", "liked_tracks": "เพลงที่ชอบ", "liked_tracks_description": "เพลงที่คุณชื่นชอบทั้งหมด", @@ -36,7 +36,7 @@ "search_local_tracks": "ค้นหาเพลงในเครื่อง...", "play": "เล่น", "delete": "ลบ", - "none": "ไม่มี" + "none": "ไม่มี", "sort_a_z": "เรียงตาม A-Z", "sort_z_a": "เรียงตาม Z-A", "sort_artist": "เรียงตามศิลปิน", @@ -65,7 +65,7 @@ "released": "เผยแพร่", "error": "ข้อผิดพลาด {error}", "title": "ชื่อ", - "time": "เวลา" + "time": "เวลา", "more_actions": "เพิ่มเติม", "download_count": "ดาวน์โหลด ({count})", "add_count_to_playlist": "เพิ่ม ({count}) ลงในเพลย์ลิสต์", @@ -101,7 +101,7 @@ "queue": "คิว", "alternative_track_sources": "แหล่งแทร็กอื่น", "download_track": "ดาวน์โหลดแทร็ก", - "tracks_in_queue": "{tracks} แทร็กในคิว" + "tracks_in_queue": "{tracks} แทร็กในคิว", "clear_all": "ล้างทั้งหมด", "show_hide_ui_on_hover": "แสดง/ซ่อน UI เมื่อโฮเวอร์", "always_on_top": "อยู่ด้านบนเสมอ", @@ -176,13 +176,13 @@ "first_go_to": "ก่อนอื่น ไปที่", "login_if_not_logged_in": "ยังไม่ได้เข้าสู่ระบบ ให้เข้าสู่ระบบ/ลงทะเบียน", "step_2": "ขั้นที่ 2", - "step_2_steps": "1. หลังจากเข้าสู่ระบบแล้ว กด F12 หรือ คลิกขวาที่เมาส์ > ตรวจสอบเพื่อเปิด Devtools เบราว์เซอร์\n2. จากนั้นไปที่แท็บ "แอปพลิเคชัน" (Chrome, Edge, Brave เป็นต้น) หรือแท็บ "ที่เก็บข้อมูล" (Firefox, Palemoon เป็นต้น)\n3. ไปที่ส่วน "คุกกี้" แล้วไปที่ subsection "https://accounts.spotify.com"", + "step_2_steps": "1. หลังจากเข้าสู่ระบบแล้ว กด F12 หรือ คลิกขวาที่เมาส์ > ตรวจสอบเพื่อเปิด Devtools เบราว์เซอร์\n2. จากนั้นไปที่แท็บ \"แอปพลิเคชัน\" (Chrome, Edge, Brave เป็นต้น) หรือแท็บ \"ที่เก็บข้อมูล\" (Firefox, Palemoon เป็นต้น)\n3. ไปที่ส่วน \"คุกกี้\" แล้วไปที่ subsection \"https: //accounts.spotify.com\"", "step_3": "ขั้นที่ 3", - "step_3_steps": "คัดลอกค่าคุกกี้ "sp_dc"", + "step_3_steps": "คัดลอกค่าคุกกี้ \"sp_dc\"", "success_emoji": "สำเร็จ", "success_message": "ตอนนี้คุณเข้าสู่ระบบด้วยบัญชี Spotify ของคุณเรียบร้อยแล้ว ยอดเยี่ยม!", "step_4": "ขั้นที่ 4", - "step_4_steps": "วางค่า "sp_dc" ที่คัดลอกมา", + "step_4_steps": "วางค่า \"sp_dc\" ที่คัดลอกมา", "something_went_wrong": "มีอะไรผิดพลาด", "piped_instance": "อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe", "piped_description": "อินสแตนซ์เซิร์ฟเวอร์แบบ Pipe ที่ใช้สำหรับการจับคู่แทร็ก", From 392047247b7d5ce5876d3122f0e496d30b28ced8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EF=BC=B7=EF=BC=A9=EF=BC=AE=EF=BC=BA=EF=BC=AF=EF=BC=B2?= =?UTF-8?q?=EF=BC=B4?= <75412448+mikropsoft@users.noreply.github.com> Date: Tue, 9 Apr 2024 19:23:19 +0300 Subject: [PATCH 035/261] chore: improve Turkish translations (#1307) * Update app_tr.arb * Update app_tr.arb * Update app_tr.arb * Update app_tr.arb * Update app_tr.arb * Update app_tr.arb * Update app_tr.arb * Update app_tr.arb * Hotfix * Update app_tr.arb * Update app_tr.arb * add * Fix * Fix * Update * Add fastlane tr * chore: add back previous translator's name --------- Co-authored-by: Kingkor Roy Tirtho --- lib/l10n/app_tr.arb | 279 +++++++++--------- lib/l10n/l10n.dart | 7 +- metadata/tr/full_description.txt | 14 + metadata/tr/images/icon.png | Bin 0 -> 91271 bytes .../tr/images/phoneScreenshots/android-1.jpg | Bin 0 -> 354210 bytes .../tr/images/phoneScreenshots/android-2.jpg | Bin 0 -> 183621 bytes .../tr/images/phoneScreenshots/android-3.jpg | Bin 0 -> 308432 bytes .../tr/images/phoneScreenshots/android-4.jpg | Bin 0 -> 480083 bytes .../tr/images/phoneScreenshots/android-5.jpg | Bin 0 -> 276613 bytes metadata/tr/short_description.txt | 1 + metadata/tr/title.txt | 1 + 11 files changed, 163 insertions(+), 139 deletions(-) create mode 100644 metadata/tr/full_description.txt create mode 100644 metadata/tr/images/icon.png create mode 100644 metadata/tr/images/phoneScreenshots/android-1.jpg create mode 100644 metadata/tr/images/phoneScreenshots/android-2.jpg create mode 100644 metadata/tr/images/phoneScreenshots/android-3.jpg create mode 100644 metadata/tr/images/phoneScreenshots/android-4.jpg create mode 100644 metadata/tr/images/phoneScreenshots/android-5.jpg create mode 100644 metadata/tr/short_description.txt create mode 100644 metadata/tr/title.txt diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 94800023..ee7562ef 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1,63 +1,64 @@ { "guest": "Misafir", - "browse": "Gözat", + "browse": "Göz at", "search": "Ara", "library": "Kütüphane", - "lyrics": "Sözler", + "lyrics": "Şarkı Sözleri", "settings": "Ayarlar", - "genre_categories_filter": "Kategorileri veya türleri filtreleyin...", + "genre_categories_filter": "Kategorileri veya türleri filtrele...", "genre": "Tür", "personalized": "Kişiselleştirilmiş", "featured": "Öne Çıkanlar", "new_releases": "Yeni Çıkanlar", "songs": "Şarkılar", - "playing_track": "Oynatılıyor {track}", - "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parçaları kaldırılacaktır\nDevam etmek istiyor musunuz?", + "playing_track": "{track} oynatılıyor", + "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parça kaldırılacak\nDevam etmek istiyor musunuz?", "load_more": "Daha fazlasını yükle", - "playlists": "Çalma Listeleri", + "playlists": "Oynatma listeleri", "artists": "Sanatçılar", "albums": "Albümler", "tracks": "Parçalar", - "downloads": "İndirmeler", - "filter_playlists": "Çalma listelerinizi filtreleyin...", + "downloads": "İndirilenler", + "filter_playlists": "Oynatma listelerinizi filtreleyin...", "liked_tracks": "Beğenilen Parçalar", "liked_tracks_description": "Beğendiğiniz tüm parçalar", - "create_playlist": "Çalma Listesi Oluştur", - "create_a_playlist": "Bir çalma listesi oluştur", - "update_playlist": "Çalma listesini güncelle", + "create_playlist": "Oynatma Listesi Oluştur", + "create_a_playlist": "Bir oynatma listesi oluşturun", + "update_playlist": "Oynatma listesini güncelle", "create": "Oluştur", "cancel": "İptal", "update": "Güncelle", - "playlist_name": "Çalma Listesi Adı", - "name_of_playlist": "Çalma listesi adı", + "playlist_name": "Oynatma Listesi Adı", + "name_of_playlist": "Oynatma listesinin adı", "description": "Açıklama", "public": "Halka açık", "collaborative": "İşbirliği", - "search_local_tracks": "Yerel parçaları arayın...", + "search_local_tracks": "Yerel parçaları ara...", "play": "Oynat", "delete": "Sil", - "none": "Hiçbiri", - "sort_a_z": "A'dan Z'ye sırala", - "sort_z_a": "Z'dan A'ye sırala", + "none": "Yok", + "sort_a_z": "A - Z'ye göre sırala", + "sort_z_a": "Z - A'ya göre sırala", "sort_artist": "Sanatçıya Göre Sırala", "sort_album": "Albüme Göre Sırala", + "sort_duration": "Süreye Göre Sırala", "sort_tracks": "Parçaları Sırala", - "currently_downloading": "Şu Anda İndiriliyor ({tracks_length})", + "currently_downloading": "Şu An İndirilenler ({tracks_length})", "cancel_all": "Tümünü İptal Et", "filter_artist": "Sanatçıları filtrele...", "followers": "{followers} Takipçiler", "add_artist_to_blacklist": "Sanatçıyı kara listeye ekle", "top_tracks": "En İyi Parçalar", - "fans_also_like": "Hayranlar ayrıca şunları beğendi", + "fans_also_like": "Hayranlar ayrıca şunları da beğendi", "loading": "Yükleniyor...", "artist": "Sanatçı", - "blacklisted": "Kara Listede", - "following": "Takip Ediliyor", - "follow": "Takip Et", + "blacklisted": "Kara listeye alındı", + "following": "Takip ediliyor", + "follow": "Takip et", "artist_url_copied": "Sanatçı bağlantısı panoya kopyalandı", - "added_to_queue": "Kuyruğa {tracks} parçaları eklendi", + "added_to_queue": "Kuyruğa {tracks} parçası eklendi", "filter_albums": "Albümleri filtrele...", - "synced": "Eşitlendi", + "synced": "Senkronize edildi", "plain": "Sade", "shuffle": "Karıştır", "search_tracks": "Parça ara...", @@ -65,56 +66,56 @@ "error": "Hata {error}", "title": "Başlık", "time": "Zaman", - "more_actions": "Daha fazla işlem", + "more_actions": "Daha fazla eylem", "download_count": "İndir ({count})", - "add_count_to_playlist": "Çalma Listesine ({count}) Ekle", - "add_count_to_queue": "Sıraya ({count}) ekle", - "play_count_next": "Oynat ({count}) sonraki", + "add_count_to_playlist": "Oynatma Listesine ({count}) ekle", + "add_count_to_queue": "Kuyruğa ({count}) ekle", + "play_count_next": "({count}) sonrakini oynat", "album": "Albüm", - "copied_to_clipboard": "Panoya {data} kopyalandı", - "add_to_following_playlists": "Aşağıdaki Çalma Listelerine {track} ekle", + "copied_to_clipboard": "{data} panoya kopyalandı", + "add_to_following_playlists": "{track} parçasını aşağıdaki Oynatma Listelerine ekle", "add": "Ekle", - "added_track_to_queue": "Sıraya {track} eklendi", + "added_track_to_queue": "{track} kuyruğa eklendi", "add_to_queue": "Kuyruğa ekle", - "track_will_play_next": "{track} sonraki çalacak", - "play_next": "Sıradaki", - "removed_track_from_queue": "Sıradan {track} kaldırıldı", - "remove_from_queue": "Kuyruktan çıkar", + "track_will_play_next": "{track} bir sonraki çalacak", + "play_next": "Sonrakini oynat", + "removed_track_from_queue": "{track} sıradan kaldırıldı", + "remove_from_queue": "Sıradan kaldır", "remove_from_favorites": "Favorilerden kaldır", "save_as_favorite": "Favori olarak kaydet", - "add_to_playlist": "Çalma listesine ekle", - "remove_from_playlist": "Çalma listesinden kaldır", + "add_to_playlist": "Oynatma listesine ekle", + "remove_from_playlist": "Oynatma listesinden kaldır", "add_to_blacklist": "Kara listeye ekle", - "remove_from_blacklist": "Kara listeden çıkar", + "remove_from_blacklist": "Kara listeden kaldır", "share": "Paylaş", "mini_player": "Mini Oynatıcı", "slide_to_seek": "İleri veya geri arama yapmak için kaydırın", - "shuffle_playlist": "Çalma listesini karıştır", - "unshuffle_playlist": "Karışık çalma listesi", + "shuffle_playlist": "Oynatma listesini karıştır", + "unshuffle_playlist": "Oynatma listesinin karışıklığını kaldır", "previous_track": "Önceki parça", "next_track": "Sonraki parça", - "pause_playback": "Çalmayı Duraklat", - "resume_playback": "Çalmaya Devam Et", + "pause_playback": "Oynatmayı duraklat", + "resume_playback": "Oynatmayı sürdür", "loop_track": "Döngü parçası", - "repeat_playlist": "Çalma listesini tekrarla", + "repeat_playlist": "Oynatma listesini tekrarla", "queue": "Sıra", - "alternative_track_sources": "Alternatif parça kaynakları", + "alternative_track_sources": "Alternatif yol kaynakları", "download_track": "Parçayı indir", - "tracks_in_queue": "{tracks} sıradaki parçalar", + "tracks_in_queue": "{tracks} parça sırada", "clear_all": "Tümünü temizle", - "show_hide_ui_on_hover": "Üzerine gelindiğinde kullanıcı arayüzünü göster/gizle", - "always_on_top": "Her zaman en üstte", + "show_hide_ui_on_hover": "Fareyle üzerine gelindiğinde kullanıcı arayüzünü göster/gizle", + "always_on_top": "Her zaman üstte", "exit_mini_player": "Mini oynatıcıdan çık", "download_location": "İndirme konumu", "account": "Hesap", - "login_with_spotify": "Spotify hesabınız ile giriş yapın", - "connect_with_spotify": "Spotify ile bağlantı kurun", + "login_with_spotify": "Spotify hesabınızla giriş yapın", + "connect_with_spotify": "Spotify ile bağlan", "logout": "Çıkış Yap", "logout_of_this_account": "Bu hesaptan çıkış yap", - "language_region": "Dil & Bölge", + "language_region": "Dil ve Bölge", "language": "Dil", "system_default": "Sistem Varsayılanı", - "market_place_region": "Mevcut Bölge", + "market_place_region": "Pazaryeri Bölgesi", "recommendation_country": "Tavsiye Edilen Ülke", "appearance": "Görünüm", "layout_mode": "Düzen Modu", @@ -123,23 +124,23 @@ "compact": "Sıkıştırılmış", "extended": "Genişletilmiş", "theme": "Tema", - "dark": "Karanlık", - "light": "Aydınlık", + "dark": "Koyu", + "light": "Açık", "system": "Sistem", "accent_color": "Vurgu Rengi", - "sync_album_color": "Albüm rengini eşitle", - "sync_album_color_description": "Albüm resminin baskın rengini vurgu rengi olarak kullanır", - "playback": "Çalma", + "sync_album_color": "Albüm rengini senkronize et", + "sync_album_color_description": "Vurgu rengi olarak albüm resminin baskın rengini kullanır", + "playback": "Oynatma", "audio_quality": "Ses Kalitesi", "high": "Yüksek", "low": "Düşük", - "pre_download_play": "Önceden indir ve oynat", - "pre_download_play_description": "Ses akışı yerine, baytları indirin ve oynatın (Daha yüksek bant genişliği kullanıcıları için önerilir)", + "pre_download_play": "Ön yükleme ve oynatma", + "pre_download_play_description": "Ses akışı yerine baytları indirin ve oynatın (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)", "skip_non_music": "Müzik olmayan bölümleri atla (SponsorBlock)", "blacklist_description": "Kara listeye alınan parçalar ve sanatçılar", - "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin bitmesini bekleyin", + "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin tamamlanmasını bekleyin", "desktop": "Masaüstü", - "close_behavior": "Yakın Davranış", + "close_behavior": "Kapatma Davranışı", "close": "Kapat", "minimize_to_tray": "Tepsiye küçült", "show_tray_icon": "Sistem tepsisi simgesini göster", @@ -147,24 +148,24 @@ "u_love_spotube": "Spotube'u sevdiğinizi biliyoruz", "check_for_updates": "Güncellemeleri kontrol et", "about_spotube": "Spotube Hakkında", - "blacklist": "Kara Liste", - "please_sponsor": "Lütfen Sponsor Olun/Bağış Yapın", - "spotube_description": "Spotube, hafif, platformlar arası, herkesin kullanabileceği ücretsiz bir Spotify istemcisidir.", + "blacklist": "Kara liste", + "please_sponsor": "Sponsor Ol/Bağış Yap", + "spotube_description": "Spotube, hafif, platformlar arası, herkes için ücretsiz bir spotify istemcisidir", "version": "Sürüm", "build_number": "Derleme Numarası", "founder": "Kurucu", "repository": "Depo", - "bug_issues": "Hata + Sorunlar", - "made_with": "❤️ ile Bangladesh🇧🇩 adresinde yapılmıştır.", + "bug_issues": "Hata+Sorunlar", + "made_with": "❤️ ile Bangladeş'te yapıldı", "kingkor_roy_tirtho": "Kingkor Roy Tirtho", "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", "license": "Lisans", - "add_spotify_credentials": "Başlamak için spotify bilgilerinizi ekleyin", - "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, bilgileriniz toplanmayacak veya kimseyle paylaşılmayacak", - "know_how_to_login": "Nasıl yapılacağını bilmiyor musunuz?", + "add_spotify_credentials": "Başlamak için spotify kimlik bilgilerinizi ekleyin", + "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, kimlik bilgilerinizden hiçbiri toplanmayacak veya kimseyle paylaşılmayacak", + "know_how_to_login": "Bunu nasıl yapacağınızı bilmiyor musunuz?", "follow_step_by_step_guide": "Adım Adım kılavuzu takip edin", - "spotify_cookie": "Spotify {name} Çerez", - "cookie_name_cookie": "{name} Çerez", + "spotify_cookie": "Spotify {name} Çerezi", + "cookie_name_cookie": "{name} Çerezi", "fill_in_all_fields": "Lütfen tüm alanları doldurun", "submit": "Gönder", "exit": "Çık", @@ -172,38 +173,40 @@ "next": "Sonraki", "done": "Bitti", "step_1": "1. Adım", - "first_go_to": "İlk önce şu adrese gidin", - "login_if_not_logged_in": "ve oturum açmadıysanız Giriş Yapın/Kaydolun", + "first_go_to": "İlk olarak şuraya gidin:", + "login_if_not_logged_in": "ve oturum açmadıysanız Oturum Açın/Kaydolun", "step_2": "2. Adım", - "step_2_steps": "1. Giriş yaptıktan sonra, Tarayıcı devtools.\n2'yi açmak için F12'ye basın veya Fare Sağ Tıklaması > İncele'ye basın. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin", + "step_2_steps": "1. Giriş yaptıktan sonra, Tarayıcı geliştirme araçlarını açmak için F12 veya Fareye Sağ Tıklayın > İncele'ye basın.\n2. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin.\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin", "step_3": "3. Adım", + "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyalayın", "success_emoji": "Başarılı🥳", - "success_message": "Şimdi Spotify hesabınızla başarılı bir şekilde oturum açtınız. İyi iş, dostum!", + "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Aferin, dostum!", "step_4": "4. Adım", - "something_went_wrong": "Bir şeyler ters gitti", + "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştırın", + "something_went_wrong": "Bir hata oluştu", "piped_instance": "Piped Sunucu Örneği", "piped_description": "Parça eşleştirme için kullanılacak Piped sunucu örneği", - "piped_warning": "Bazıları iyi çalışmayabilir. Bu yüzden riski size ait olmak üzere kullanın", - "generate_playlist": "Çalma Listesi Oluştur", - "track_exists": "Track {track} zaten mevcut", + "piped_warning": "Bazıları iyi çalışmayabilir. Yani riski size ait olmak üzere kullanın", + "generate_playlist": "Oynatma Listesi Oluştur", + "track_exists": "{track} parçası zaten var", "replace_downloaded_tracks": "İndirilen tüm parçaları değiştir", "skip_download_tracks": "İndirilen tüm parçaları indirmeyi atla", - "do_you_want_to_replace": "Mevcut parçayı değiştirmek mi istiyorsunuz?", + "do_you_want_to_replace": "Mevcut parçayı değiştirmek istiyor musunuz?", "replace": "Değiştir", "skip": "Atla", "select_up_to_count_type": "En fazla {count} {type} seçin", - "select_genres": "Tür Seç", + "select_genres": "Türleri Seç", "add_genres": "Tür Ekle", "country": "Ülke", "number_of_tracks_generate": "Oluşturulacak parça sayısı", "acousticness": "Akustiklik", - "danceability": "Dansedilebilirlik", + "danceability": "Dans Edilebilirlik", "energy": "Enerji", - "instrumentalness": "Enstrümansallık", + "instrumentalness": "Araçsallık", "liveness": "Canlılık", - "loudness": "Yükseklik", + "loudness": "Ses yüksekliği", "speechiness": "Konuşkanlık", - "valence": "Değerlilik", + "valence": "Değerlik", "popularity": "Popülerlik", "key": "Anahtar", "duration": "Süre (sn)", @@ -220,30 +223,30 @@ "deselect_all": "Tüm Seçimleri Kaldır", "select_all": "Tümünü Seç", "are_you_sure": "Emin misiniz?", - "generating_playlist": "Özel çalma listenizi oluşturun...", - "selected_count_tracks": "Seçilen {count} parçalar", - "download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapmış ve yaratıcı Müzik toplumuna zarar vermiş olursunuz. Umarım bunun farkındasınızdır. Her zaman, Sanatçıların sıkı çalışmalarına saygı duymayı ve desteklemeyi deneyin", - "download_ip_ban_warning": "Bu arada, normalden fazla indirme isteği nedeniyle IP adresiniz YouTube'da engellenebilir. IP engeli, o IP cihazından en az 2-3 ay boyunca YouTube'u (giriş yapmış olsanız bile) kullanamayacağınız anlamına gelir. Ve Spotube böyle bir durumda herhangi bir sorumluluk kabul etmez", - "by_clicking_accept_terms": "'Kabul et' seçeneğine tıklayarak aşağıdaki şartları kabul etmiş olursunuz:", - "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben malım.", - "download_agreement_2": "Sanatçıları elimden geldiğince destekleyeceğim ve bunu sadece sanatlarını satın alacak param olmadığı için yapıyorum", - "download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut eylemimin neden olduğu herhangi bir kazadan Spotube'u veya sahiplerini/dağıtıcılarını sorumlu tutmuyorum", + "generating_playlist": "Özel oynatma listeniz oluşturuluyor...", + "selected_count_tracks": "{count} parça seçildi", + "download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapıyor ve Müziğin yaratıcı toplumuna zarar veriyorsunuz demektir. Umarım bunun farkındasınızdır. Her zaman, Sanatçının sıkı çalışmasına saygı duymaya ve desteklemeye çalışın", + "download_ip_ban_warning": "Bu arada, IP'niz normalden daha fazla indirme isteği nedeniyle YouTube'da engellenebilir. IP engelleme, YouTube'u (oturum açmış olsanız bile) o IP cihazından en az 2 -3 ay kullanamayacağınız anlamına gelir. Ve eğer böyle bir şey olursa Spotube'un hiçbir sorumluluğu yok", + "by_clicking_accept_terms": "\"Kabul et\" e tıklayarak aşağıdaki şartları kabul etmiş olursunuz:", + "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben kötüyüm", + "download_agreement_2": "Sanatçıyı elimden geldiğince destekleyeceğim ve bunu sadece sanatını satın alacak param olmadığı için yapıyorum", + "download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut işlemimden kaynaklanan herhangi bir kazadan Spotube'u veya sahiplerini/katkıda bulunanlarını sorumlu tutmuyorum", "decline": "Reddet", "accept": "Kabul et", "details": "Detaylar", "youtube": "YouTube", "channel": "Kanal", "likes": "Beğeniler", - "dislikes": "Beğenmemeler", + "dislikes": "Beğenmeyenler", "views": "İzlenmeler", - "streamUrl": "Yayın Bağlantısı", - "stop": "Dur", - "sort_newest": "En yeni eklenene göre sırala", - "sort_oldest": "En eski eklenene göre sırala", + "streamUrl": "Akış bağlantısı", + "stop": "Durdur", + "sort_newest": "En yeniye göre sırala", + "sort_oldest": "Eklenen en eskiye göre sırala", "sleep_timer": "Uyku Zamanlayıcısı", - "mins": "{minutes} Dakikalar", - "hours": "{hours} Saat", - "hour": "{hours} Saatler", + "mins": "{minutes} Dakika", + "hours": "{hours} Saatler", + "hour": "{hours} Saat", "custom_hours": "Özel Saatler", "logs": "Günlükler", "developers": "Geliştiriciler", @@ -252,66 +255,70 @@ "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.", + "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde saklamak için şifreleme kullanır. Ama başaramadı. Bu yüzden güvensiz depolamaya geri dönecek\nLinux kullanıyorsanız, lütfen herhangi bir gizli servisin (gnome - anahtarlık, kde - cüzdan, keepassxc vb.) yüklü olduğundan emin olun", "querying_info": "Bilgi sorgulanıyor...", "piped_api_down": "Piped API kapalı", - "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nYa örneği değiştirin ya da 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun", + "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nÖrneği değiştirin veya 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun", "you_are_offline": "Şu anda çevrimdışısınız", - "connection_restored": "İnternet bağlantınız yeniden kuruldu", + "connection_restored": "İnternet bağlantınız geri yüklendi", "use_system_title_bar": "Sistem başlık çubuğunu kullan", - "crunching_results": "Sonuçlar kırılıyor...", - "search_to_get_results": "Sonuç almak için arama yap", - "use_amoled_mode": "AMOLED modunu kullan", - "pitch_dark_theme": "Zifiri siyah dart teması", + "crunching_results": "Sonuçlar...", + "search_to_get_results": "Sonuç almak için ara", + "use_amoled_mode": "AMOLED Modunu Kullan", + "pitch_dark_theme": "Zifiri karanlık koyu tema", "normalize_audio": "Sesi normalleştir", "change_cover": "Kapağı değiştir", "add_cover": "Kapak ekle", "restore_defaults": "Varsayılanları geri yükle", - "download_music_codec": "Müzik codec bileşenini indirin", - "streaming_music_codec": "Müzik akışı codec bileşeni", + "download_music_codec": "Müzik codec bileşenini indir", + "streaming_music_codec": "Müzik codec'i akışı", "login_with_lastfm": "Last.fm ile giriş yap", "connect": "Bağlan", "disconnect_lastfm": "Last.fm bağlantısını kes", - "disconnect": "Bağlantıyı Kes", - "username": "Kullanıcı Adı", - "password": "Şifre", - "login": "Giriş Yap", - "login_with_your_lastfm": "Last.fm hesabınız ile giriş yapın", + "disconnect": "Bağlantıyı kes", + "username": "Kullanıcı adı", + "password": "Parola", + "login": "Giriş", + "login_with_your_lastfm": "Last.fm hesabınızla giriş yapın", "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", + "discord_rich_presence": "Discord Zengin Varlığı", + "browse_all": "Tümüne Göz At", "genres": "Müzik Türleri", "explore_genres": "Türleri Keşfet", - "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyala", - "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştır", "friends": "Arkadaşlar", - "no_lyrics_available": "Üzgünüz, bu parça için şarkı sözleri bulunamıyor", - "sort_duration": "Süreye Göre Sırala", + "no_lyrics_available": "Üzgünüz, bu parçanın sözleri bulunamıyor", "start_a_radio": "Radyo Başlat", "how_to_start_radio": "Radyoyu nasıl başlatmak istersiniz?", "replace_queue_question": "Mevcut kuyruğu değiştirmek mi yoksa eklemek mi istersiniz?", - "endless_playback": "Sonsuz Çalma", - "delete_playlist": "Çalma Listesini Sil", - "delete_playlist_confirmation": "Bu çalma listesini silmek istediğinizden emin misiniz?", + "endless_playback": "Sonsuz Olarak Oynat", + "delete_playlist": "Oynatma Listesini Sil", + "delete_playlist_confirmation": "Bu oynatma listesini silmek istediğinizden emin misiniz?", "local_tracks": "Yerel Parçalar", "song_link": "Şarkı Bağlantısı", "skip_this_nonsense": "Bu saçmalığı atla", - "freedom_of_music": "“Müziğin Özgürlüğü”", - "freedom_of_music_palm": "“Müziğin Özgürlüğü avucunuzun içinde”", - "get_started": "Başlayalım", - "youtube_source_description": "Tavsiye edilir ve en iyi çalışır.", + "freedom_of_music": "“Müzik Özgürlüğü”", + "freedom_of_music_palm": "“Müzik Özgürlüğü avucunuzun içinde”", + "get_started": "Haydi başlayalım", + "youtube_source_description": "Tavsiye edilir ve en iyi şekilde çalışır.", "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı ama çok daha fazla ücretsiz.", "jiosaavn_source_description": "Güney Asya bölgesi için en iyisi.", "highest_quality": "En Yüksek Kalite: {quality}", "select_audio_source": "Ses Kaynağını Seç", - "endless_playback_description": "Yeni şarkıları otomatik olarak sıraya ekle\nsonuna", - "choose_your_region": "Bölgenizi Seçin", - "choose_your_region_description": "Bu, Spotube'un konumunuza uygun doğru içeriği göstermesine yardımcı olacaktır.", - "choose_your_language": "Dilinizi Seçin", - "help_project_grow": "Bu projenin büyümesine yardımcı olun", - "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Bu projenin büyümesine, projeye katkıda bulunarak, hataları raporlayarak veya yeni özellikler önererek yardımcı olabilirsiniz.", - "contribute_on_github": "GitHub'da Katkıda Bulun", - "donate_on_open_collective": "Açık Topluluğa Bağış Yapın", - "browse_anonymously": "Anonim Olarak Göz At" + "endless_playback_description": "Yeni şarkıları otomatik olarak \nkuyruğun sonuna ekle", + "choose_your_region": "Bölgenizi seçin", + "choose_your_region_description": "Bu, Spotube'un size doğru içeriği göstermesine yardımcı olacaktır\nkonumunuz için.", + "choose_your_language": "Dilinizi seçin", + "help_project_grow": "Bu projenin büyümesine yardımcı ol", + "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.", + "contribute_on_github": "GitHub'a katkıda bulunun", + "donate_on_open_collective": "Open Collective'e bağış yap", + "browse_anonymously": "Anonim Olarak Göz at" + "enable_connect": "Bağlantıyı Etkinleştir", + "enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin", + "devices": "Cihazlar", + "select": "Seç", + "connect_client_alert": "{client} tarafından kontrol ediliyorsun.", + "this_device": "Bu Cihaz", + "remote": "Yönet" } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 4ba9254e..180d2ec6 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -7,11 +7,12 @@ /// TexturedPolak@github => Polish /// yuri-val@github => Ukrainian /// energywave@github, ncvescera@github, OpenCode@github => Italian -/// mdksec@github => Turkish +/// mdksec@github, mikropsoft@github => Turkish /// Stephan-P@github, SecularSteve@github => Dutch /// doannc2212@github => Vietnamese /// sappho192@github => Korean /// watchakorn-18k@github => Thai + import 'package:flutter/material.dart'; class L10n { @@ -22,7 +23,7 @@ class L10n { const Locale('ca', 'AD'), const Locale('de', 'GE'), const Locale('es', 'ES'), - const Locale("fa", "IR"), + const Locale('fa', 'IR'), const Locale('fr', 'FR'), const Locale('ne', 'NP'), const Locale('hi', 'IN'), @@ -39,4 +40,4 @@ class L10n { const Locale('zh', 'CN'), const Locale('vi', 'VN'), ]; -} +} \ No newline at end of file diff --git a/metadata/tr/full_description.txt b/metadata/tr/full_description.txt new file mode 100644 index 00000000..8b8b814c --- /dev/null +++ b/metadata/tr/full_description.txt @@ -0,0 +1,14 @@ +Premium gerektirmeyen ve Electron kullanmayan açık kaynaklı Spotify istemcisi! Hem masaüstü hem de mobil için kullanılabilir! + + +Özellikler: +* Herkese açık ve ücretsiz Spotify ve YT Music API'lerinin kullanımı sayesinde reklam yok¹ +* İndirilebilir parçalar +* Çapraz platform desteği +* Küçük boyut ve daha az veri kullanımı +* Anonim/misafir girişi +* Zaman senkronize şarkı sözleri +* Telemetri, tanılama veya kullanıcı verisi toplama yok +* Yerel performans +* Açık kaynak yazılım +* Oynatma kontrolü sunucu üzerinde değil, yerel olarak yapılır diff --git a/metadata/tr/images/icon.png b/metadata/tr/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b24a8c238dbd80e359026b114a0c097de9422823 GIT binary patch literal 91271 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 H-`)QM?;rZo literal 0 HcmV?d00001 diff --git a/metadata/tr/images/phoneScreenshots/android-1.jpg b/metadata/tr/images/phoneScreenshots/android-1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ae1ef8ac5552a98dcfb0c9544af5d7d1262cbfb6 GIT binary patch literal 354210 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%0D<7{1R8gD3k267jZ1L1#)5?q+}+)^ zaceZVLvYvN?hfy5^6u|D``r87{tc_=T3t12)EG7AiVPcOjm6#Wr9;~>EnJ;Y8wAk| zaSqz=as>r*TkB=QwToMx&^#$u`J9~qORl$buZ`1e!w9XB778jR%H4|R2y7U7|KH!N zU3g4*?-An|+*PHo_&*c}PXWlKQm!lA^vRe)5AM%TR0>N!9?Ix%2j2nv4)toFaF=#R zYOft(p?qNk+0tzdKA?JTLQWj<*f`7oeEWaC#oAl>oysG}uR!UiR5_i+a&Fh99jWAt z|J=5QKE?d5_Q{t3+;#us$+@~J{WpRUo__!N@BjRncAmknmW6S<#vOpsNZrL#ycrpQ=k%A&4gse#AQJ6F-kJmGp3YCrj@$ zot{7uLTEwTHnlaN(}-2UuDzk1TSKJiG{i7YSxM*VJW|`$v8Y6XGk&y*GW^SODx{)Q zfKF%1mLv+^tnuu>qeNpG4f^gppMlT}^qEoZK(Qu1x~SKv4N?b; zHa8q7R|Xtyx(xT|{aH$G&+6Bn6B?N1(%thg)2fn*l@TEPSBUQQ!rk+s8>%zKY1BZ`9w2wTxnkU4~%Oo zG@fVjf1bV^sEE_s?0`An+fU#I7TB@(f9L65J17=v{rpmzac7SrQoJHrvyd;4ev?e) z(8pSxi?GP8c0X}kwWSz;qO41uxXrJ7`ScG=L@<@WZ$qNsJCCnnKXv7OEuOP%>={4R zhcTablosd#oeql6m^E;Q305M=7mmL}#Lp!&N~O1yB@~Yfr?nE+D;^3)PJly8! z5Q*&}i?%4+{ZobQ^tB=mKv(|bY zXe^gh39i(`}eY9WUz$J|-pykp^Z6Hv$ zblYnnw9H?=I6G!Vx;2;;!GOZ!ADHmlVr^IFa`b_i0Ma$92E$QvFD{eg^)rE4m1!k4D zd;Dq^u`(RV8c`IDO@Hi7ruXZ{9CW}q4jCl0!6Dcj`~#zXJR#B-!*od_fbnB@T8{@; z7}0^pV|N@@`Br(plU;Ak2S~Us))MVC=IlwlAbg`U$=dSBlmS5^6jPdxHQCPFo5T*( zHxklqca)&x|C0cR{`mm<0uZ;_JrX5%ykNMtZk$tvm8YoOl2tkz8y9Pv(w^BpV@F=)HZ8BU_dY20W)}C zE6(`?thS<9a6$m<74&w_*|I^-E_L|G=dOi1ssu^^ez0|Uz7grIeFNKPSVmL%wvohbeHYpPD@@z|8>Y{z zD|QLa$@pJ-HgSi$qGy)HlkoKD=zcD5sJ`_a z^%Ju!ng`b`y1&)Ai0XMFO?58tC&AA#reHG~}BWAl95`npF}gu%fM6SQf?7 zj6Ip$<^%SqKf%|J>vG|!ebH@`JyL~bqsE_BY89Po^o&NLreqfcc6!RYp?c3p41NnJ zJ>ijCp0|m;k=Sr8OhLUOGE}_vi%>snCTac?7YoEXurDis3 z7>;lIzc+w?`@hm|{rgkxf6|V6gI>0h4+m?kvo$P`OFiHfPc>(u%NKv_kUF+!8vxDmt&acXxFH&|c))Gc z{w}_>K@23P)5wMXk{-atIxVi$rqfkJ^Z70CC^-H{3Kkmp) z?88AF-A^sxHB}`RVaWk38RFO_?9dMyd}|yDK3y2OGRe@8)t3^VdPj-~68e@LEwv*4 z|Hg+u0<#9&ec@1i$jbjYJ_FCKi5kJ_M*`9)eR?BB_g(#pn7p>^*}75uu}(9#Fr|8< zpM2PKLvWL=mW#1Zhl21=Hwn*EvF$3KcUaE#vO8&~5}ZTR5|xhmKqIl;X@}h?B3j=; z+LvlwOXyk1to6Od&1WxXqcR1Pj8V!coeQ!$`B9j&aOVgMVQ7!X>T!!#Kfu42PLG}# z8a@G=tmver4iwz-!0Pv|a=Mao`dNx0FQ`FwDSY$c?}Y&SO)+P3zAd|$-vCj~k$ot! z(9&Bx_m*JUZ^tulerNEu79l2JlX2E;3)vD;L`5bpF~VUIJK+BdQ?8BLCp^XtTnz*! zu~S_!y+mdQhsm3$>MfItYRmxzOu4&lR*&1~Ct9G@@G`jR2J3Q}E{}$ajYi?)qU$J! zE~!oROxwiUP2*Y&Yro%*&yq5{`(7JYwxZrtn};-sg5FZOoFma%OuZkydqr#-IyY@q zWpTC5*l{M2r)t|wZ^P9G{7!6sXqmQK9o}SnCVK7O`mFD%hfJO2puQ*UwhIax_6Z?B z@xyX|KQuek0xcMd$?i%gzxpwLRS^uX>E@Hc%Ow>49+Nh*=RACZSWgTE??MhoZW)rs zXia@IJkXW>nbHy->+_WK?fX70W@8`PZzQA+ai*0smjj#5nWs$OF4ZN}iJC4c>z#@_ z?aF(`4R)venb!XPr@%~P&d0)jk#-|fGV+f$kH$yoQNbL)4zujJ#i%c3sqSHx%^M+C=VzQ9`|zMOscI2*<4(H@03(YAW`oyO?BOmo9DTl(o`FG>}_W zXA#&uB$;!X_-KvID%PhqygXv-3!K}D3t&)9}@1a+s*-%=w5goJhA zLR}vIkP=IudN_i5J-Bwo7E8-rMr<|Am?ou{G0K8RL<3F6K#MO}pyt%RYUmni zR_zy9keKw}=keMg7)`ovif><75%Jp3THe%BtLUnVCP2-ni=>(bqh@`CiOyg#U}eCW zZMIxwSyL!)g_ro$4XZGGRO4m4$spENqHZq3@Cyn83i%!@Vvm;)>x+L=3T(JDk~NE$)QZ7>M^qp9#;FgiF~vkOxz%5>M|Vm*dtF*paMi zyo?#vAFNipZ#{DsVMA-b9nd{0R0Ol|(6B>2Gal+-+wIKs(duiObDJKElolReA9SMN z%*Fy_Cejj)7c;>*(En36N}rw*<5n5AllzsFNkbrq{y<=qdDGZ+(!C=<%NB8bQq=sw zDT@(JGJczXs}gwkHd5sYyvHkMuJJH;O53ES9jbrFWB?u!AP}&@1I<^WGv@%oUQ|Kc zgB9hE+9xnAIvdNz0sydRj}Xt-9jyGjLCN;RXNPULIwb2KLR9&jewcP8kgAJHVoeUo zJ#BTEO31+oC_pY4&S;gD3!q%*!Q*BABFcQ|?Wo|nySGYl!7YK!_ zI0&|ko1l!T-UJpxJ^cwZa>-4{pbT3H5Zcw5OL$C27}Wl&q53lnlc{_wl=J~T z3g}BKfS`6031Ans+86zke0?>1NtT$l?~nEqgsIyYm%cXiZx=Xk!jdT)8NCy?7}_cr zCrarZjg>svFOpbmmzU6jXcO}hk!iToLub^XC+VzT$zRC^6j2X$j|X=}!Oz%qCktQw zZ7KhzJfUJ!3x@wL*-`W+0CS%vWAd8vTlfhXVjffi0Hv31(tEw~IJVMKUL**#shQ`5 zHZ`|u5}=Kegjx#}InOQ4-!0W$>#-hQ-isNX*7Wsk!eaazom}Fh4f_exsYcrcTairt zEjt%l+ponbx1M+&)7R0L^ix&drdgH_(^C-dtaDV&l^;-G%6HgHB&ij7g;^;Co+NDauajHVRfi!{XHa*CN4|zL(Mj7vOlQFX11M zfdiE!`O(V)gOV!)23tZ&&I}EUshAOi`lT!mlixjCNHR}G0fjza_&*na%oX;w^TaIl^oO%C05V%OGIvPz|om@#B zIvK-RkW(q39b12jg+`lSuZdiao3b%;s{)~xx1^A&JL{0so{x$~!?riHG~?;M!$;I$ zdXsPW#&ZS6Sggxt81i)yf45<`h($f9%zSA?c(kDc=isB&LMx)dX;WoUTZ!q-53SRy z`ny!uBgEsOMwRmrGf74UMpW@v(~KD!l=Hv0<)Mjy3h0>vK&hm#b+7nO;RD)a>S3s9 zRD#DNPnZ?Cqgl}^uT|2=&(%Pj0Q!)ZdDs9t*9RI>=^q*IF zZTb(qCP3j;B2!f)+G4j^DAdIN)GUDl(R88w_(dzio#uc~rDm3yPz=3nM5+wRwgPB$ zLD{(o(1hr4kye>TjgJ{zKARo1>^S(MQ*nP%Z&qICFxOnSP#%6xn5Aq`FB5A!QQ#!N z8Q22ZXJ?Siq$b}^(zTk1Z^E*3%C%JBm3b#w=X&V97EJgfbhk}?w7~f$J}^oYg@h)A zP!W2TSyOH$HWydwWub9FjVD4CdLQpbq!MU_uIms9pD(U3a{Yx#!JqE;k@|W#06N6h zSv#H;fB=yq?6wUE8gdJv49kOG72N7$zBt3 zE)xD{X6+{^r{wz|PATD|@$f&KvLSLxYDS}};n$T7n9fi*C8>xEfv++7K~628Z&*V` zVlprUNqsG_tERj1O1L!TN$^qHPgi9kXEG^kD-J^d1Bgdk8fq)*9`k;oh8dpG4sE3z z;2qqwX%6Sn!A4s8^e6f`Ouw?R;rvK5$42>YMA4NoK_}&j&z9ia z#3h7XL0i0TJ&h+B*qUryvTys|LHyhH^?eiwrxdz7^n8)(l^)mh1ScBJ6TL08st~)aq*$9sW=nE3pQ-W4ejILNc`Uc|3{on6W%Zdf6~3zWn?Z#a-N_}{rD`e|XdQ?2g88=q^b7sI8j1ZH7aWs}v1AK1OjI-f zt9pG3g9^$J!g{}Y9WswyqSpfOb!oZVii>iYN*1^a$aSCRiGeQp_d4~qt0mYP3)u?b zz3b}{?zlZtCHoyKtociW(5J^A{_S`_uoQD!I}cZ^`nsaP6&5>YEa(-@sQPifxIJW) zP5hzfc0WCx>GFxcqoBeK0dKJ9|M1=ESrFqBme?K}pB$R~AsI9e?iXRspX3>3G^;r! z(0ajmCAMUUTV4vxCs1MwA5KYRjT7^>bNaP_#kpwsxJWfDSCCfdZ`lT!*B9}vY|4qK zK^BPBx=3|H7*3^|F2gBV1{Se#c}nn7lrc*D5CObc~gaBhrcB!*ae!QzEcUecmWkZ4f+vHy;rkx$iNrT1NMHRnLU zPhF)1Eyu4>&7s_{BK5`$VCS}y0SGi+sNnE6GeD)ZP5iy;+!-dKhQIJtSXG)cU{Jc^ z?rpfr(K1;MVus$KK}S7b`s_B5c3R+6-ErughfGBBibdX(E_=;FV2|OQh-*YKqXrX! zi@s}?`o1Eef>7d=xl>g6F|O!hxyQ{6R##H^k=pl^X~#L}NH@W?kH3k&1Ct)})Tv4a z`=%Ry$NGTlLhT&Jj|%B#5Vno5d&_O&I)3-bk6D!1lgEeR+9C4`6yjT{YWbK~5RNd> zIvN?kpA-I`A4kK4CmEg30SUc^O3&!|hmmU7qG%O-NoUNsdVqr!dRrcs+sYYNqW2i+ z4x6hgoOg@HK!>F-T`i;xgLoPpf%%yza;_Anva?TONu>hG7-9p7OFMO~YsbHtREasm zUXcCFvqVtQp}^S-1Y5HvI*CJ@Kv3&@Kcjj*2lnbo~AqN$v$6dO_@dmOSPv$&u4oC)N~d;*ZD65OP$! zF~GRiJ+#u!U&h&RCzTjphO$EVt}3OJpfUkpsk%k*yi?ky9Q{ei1c}ODNnVEC=^hZW z^<^HvM{I?1I&7tVz89ITI)VM3T_*`m9Z(9j*%)@jHJAxgltn$8W8A+O; z5q9V&3}mIw(VWx|bx2e$orZ3^Y0y{Rv0(s}G1#l>mKX57pi{e~Bo*`NU4-cWhlgUB zFY5(&fwFBJDZVDrksNLx@J25!e>T=pImLon$_G7{^^qPM5zzivZ|F!BzOxW2C;kW- z{RbvrTp@0#Bvn6aQypd7Nt2uJB&d)Q3pQOm@>k6gw7(Ub_WD2k5O*770OGQSvuyZv z8z;Ak#G{5Fv+~(Wtt=j5)waR9CIElIj6b7|4Xar27amZo0A8^26#~RtRt)J)Vv3+E zOr!W6y@3N6c>Cwx90iFzwO)J8vFmHKe_$})^5wU|z7dQHDVSlEi}`KQCECcQh0aJK z_eyDaB;0P*JZ**q0FI?5b$nRNxt;(r)06@LN`9?-IiDC15{MQR(?&pKi;Y} zZVTLbA}!H)Q_Q{A4v~4fvPvh~PRqx0)xKA`Q>rN9M+zO?4mMM}8(ny`e>B0-F(0YC zlqvNzxi2_SY{L4>cJ^wJ$d_}hGjr?OR9n+ch)ndD8VWB0z*36SpsmvEHiX;ONR76k zUH5*3N~nV&_JKFxqOo#WDw)ZDS-l%y_=oJY#mAx_6Tjxw7$yO`4JJ<*{|;WA!pu;+?ynLoK;B2Y-1`s!ZJ4Ahr^~PpLYYX-q<86$b%Xp+%>Nm&-RwldwZsW&iiT@%pUt-zlFVxyJl$rHgR8!HPR z*X(qB?-7?JfKO{qV`Y3Oov5G&fU5b3WSjnhK|(fRSY%@x_Yz_wzoXowbcIVi)bU9` zQl9)PDb2h}N^%Kh9cQMo9;Mvndu!`osm%U)aIt_ZD@@7i)Odcdt4ea$;eqBl~55z@H;^^QDlTu z@13eL+PDl`Yi&gDH+ROZvPp2$Z!cxs%y{}KB{XrwzU4XCdjeljyP%awfiZP0I+`eH zPmkRza6cj?wCX5M$6pE}%ME_z|4`f0^73pWEZVW$b$~W1ww*SXxtz#dEAX$O^mm-Z zjUT7sI$1dF)7XuKYUIQHUZ-C!T;{5a) z5gIvxBb~Eq^hjtYgL2%gm48PB4A?ziYeDgYdqgL*3Wo{{_pmB9+hSk&@n>%BQWVd` zQt0PZTdjCCdmQxJWd9|(jxU_z8uS#Z3`h#2!XFthq)4eM=7M0QFa9=o8m77r#hMgoY8~|G*Ib zH{@8|*5d|Z=Bby!wl8W-Qh!la0|>n?`iTb4KwvpOW5b>Ph4JHs;ta;8jA`*xT+>CH zjs?t`O1Xr>Q=F> zp)?bERD;E#Vmg?3;tuaLH%8mbqGpHwrDv?-x)SL8lS3G6e)`&_fK^0T732pt30F zn*QQ<7n@f^|J?*(g_^Z8&~U#bsC%1U0w1t=Y#ALw`hT2ySU04wjzp}4oa)aJKXnz> zg!S`-P})W$8w&N!Mu39?4fpf^HZIxTvIqZ4S}&aV!RFaLJVH=MJ>#bm{hM4l7jRQ3 zZvFp4GJ%9%5L+2M1F+`(UnE=1|EzcbFe}h47#a5fO5XPYa9&%B?0WtLCk1QmWGev8 z-I$>9kdGTNGt^RdwnGGoFme6{A1U`B!N&d_>X;bQHOpH(QGJ$OUAGA_uc!G2{G9l` zbW96}LxoxPB&ai=&{&&r<+Zcesa z<-ksMAXFbB-MSD+NCEZNj3=E^wsfNig_+xPC?m)`WcLkI6n&QF406H`J^ex3%RK0h z&4yOiX8pn$@>XpQ)Q{PmYdIfNC3AYJuk*vWwgpMMZ}`<<5l>g`Xu;CGm4d#Y%B^-J zrf)wnj6)=m$bHvJMg=r+C59g0#`h`)=fr1d{QenA?$XZ=ov8tI$FV&xe{G5kDx&Cv zuCw?y@tZM6%R6oE;g(7Rps?XqM>LbuG7dTHZ!mvw&cFIaOma^Ge!H}C2gYuZu zjP5qjfcqKq|HiA|#^n0pSU4xXg+Ny8wBHu@jyYkF@VDY>(^^R>D(PioC9PS zXk3&UCV+S?H9&PmkOnixLUE#dZQK6yFFo!BGouCu!cC?2;pDOdUHY;lW+3pAEeZgo zra7M;5~llDEd#l*PWE1hOUhDr=%L$YJ2qxfwml`e88YmqHbO%HTdm`1E|NtI8i?$B zP)Qh8mDyw~a;6)gV*@@#y^5j!Qw-%n@TYRC7c3mhrR|2n&;kjyajje!ASGaHDA9p7 zVu)8<$v0@5MEPoLF4(?9ZOdcSc~URLK_A2~vne=ICIFvn!hW~zX1TnMdZBu3;FS{* zjjZ8;e4Gj9)+O))eozvjQ5Na8tq67_B3*C?{4dgZGGQc}Dvm6@#W9*+PwD8kT`MOF zDVeb%2u$p>xoaS_d6e#syn5_BocId%DjP$=Arn|1 zzfCpp@rRyb&fx8Ma=Lv0!<;VCNKX;#O)Vz+)C^-xWb{S6$E5Vxl>0)+-+5WRo=Jdvu2|g|ZjVoza@T(9maN zzg+r0=$UTPB}2mz!}O;>lvq2dPj8@=ETAZ_3?58bqB72RAO=}3O)j_q&|isU9!=_DjsTNy>>&0)D*{MA4e_ zCvY)Guzzq+Ohi_QD41M(`fP2xmaj-3CW$5^fF+R z@)IXn>00A`{Rl%qO0(6TI_pD_@24gu%3;BsAKGqGmXUZpT$9@%qOEf*EdkBd#81uQ zPh8qt;-GqEPP5eS`OS5xdx*CJ@WG8JQAQ{Y`qWw#Q|Bc&73f{z$^UMc zolZ=1Vq#MlU3yxX+{m=6Q^8eEB`@M=>&plj3Jqf=quecr5Ny$V)5f37pYyeuE?{7= zhTKNHu?ot&;;EmOE^Ky`Qy*<=|H7;oDNJyV@6jx9?B-hAckcJOFuf`85JtBuQzh;g zVb}ExwI@=j@5#)0GymQrODmJAl~TZY4bDDE`7SK(3BO1p#kg74|F%g5aL%CB^WK4B z5ZK6Yy?JtCo$zZC?`#-8w#`cF9~d$;m%=8i>^6c%XT#23B$tAQDnXXWcMjj=&+sUm zcjlVypk{MDzQkLOOe8!cuzLH6>4ioJ*KM$?fPgM~8)c%G?{FIRmw*+XYtoDc&heZ0 z_*)*!e_(jDGwAfU57nD;>%?`^>lB|Tgiee{%vnHYd8!Fm2p&uVHJpm_f>}H($~YbX z({F}dO|eWPtq1rAyN(>t^F)vju)-;}!)Llvn?|*hhv!f~p*QFI$84p;nNu9`xN{5S z%Y|!{>3N1>>01~vupXFj2s_!H*waZeIuE$a~mVw zlij3sCL;F?!*p5AJ3uY zd40Ei-u$rz5<$2iEUtb@=sd`=l_+1+>c7FX5K|=Eh~cQQf5DUp;Q;ijCf*&Na9N$I;l5mr0j0C z$&A@0z8ww&NDgsT!+j8-2w+_h(H6qYP&NhuF!m1~8$Z-73rUsyr0Y>URdwWs&zR

K$+t`oTbd4o(uJZktQ{NBUgrv3@ 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%0AoOTfnF`7%N5KLZKsCOvcikD$~ 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

~ zn%z>_aZLJwEakrSHOW1Z(eUrK~| z$7gk=c_Wl^NXKm?v;((QUrNgm`HJo*YqUTJr#r-Cam>dg&dEtdbghvMvdQ5L*UPO7 zh;uSZ+5JUIz4|+EDn-Nx#)kvdts$ALg{-tj&OVp6W$%GzK@hkaHO@L+3ByqL8eZ0Q z4q$#ygKs~7OguErj8Kq&+gRE85+1u88ja4N7h6!ua&Wubm3dd{DyhC7xi z^DG_ZaRG1=0<7QV7Z;fTR07hyX7N^KzFqg|{BZ`HM)osBm!;>SI^zhf%OafA#t(4+ z`9$A!)fgk{a+WpDSE$Mz*s)y7fu7(635bkWgsM=4)8x;wztW}1+572UBe%d2aA{9P zyc_Zayv%)QalFcM^W{rn(YrK} z53nlGCM$NxCbszsn7ACKDC+_aT-N@|oM{8*%Lz#@Kg_8kBRsjb+7*Umc?{@06`)uQ z*WkCr>T$cB6r9N36{nT=m#SxQo3Kh7yw;08CxU13Blc5^(wc#Z6zbwYjA5O z?N3%r;8pvz_+2qV!%pw!<^vMeDRd~WF#+cOV)C3I+VC@)<(iq3N{)nT0JEE7g?l|w z*=abtLZS?HA5Zbu0FJHdJ-g`l=gs20>X0QH%=maE!dFgdUGa)eVJ4xLW8%fKLQh3xUEitkM`T!V+Wn z=DrgqUil@HgjP~&7T`?u{H5gC+`nT=+y-9K?%6_IIX{7flhWZG!j7iL>k-{q-JC-R zbjj~x6p(4w5IxeE3`zH{xv)vS= zb@ik?)zv6{PonxEPvrlH86L39z@97RXO~H!e92*zoU}D!U4IxaWU-Zg?ZDj2id!

9(`6J^z51TX9O)TnxR{{~TPDZ0&JzHWvZ% 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 PN9idA=6UVLKXd;F%bWv0 literal 0 HcmV?d00001 diff --git a/metadata/tr/images/phoneScreenshots/android-3.jpg b/metadata/tr/images/phoneScreenshots/android-3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..87619b212e28bd0f1789c7e500a7a7d74373c5d3 GIT binary patch literal 308432 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 z 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

@}nOVJ(hkICrh-$AWbb-7)qV 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

Ex?R2pq4i> zI1x1Pxgn5aKpF3$Fy9@ADr)lq5z^{X4>s9Wg4~Zu?0zvN{KvKi*R;SxGW*;7pitzv zoBggNXg&dp7yZn5#v<3@j473jKAj~?mb}}%=C<2Exny>L@8_zV zSKABD`pK<5r$I*q(uTdeWa3&0)2*_v0-Z=fGMsF(#>iZlwRTkK3B#{2Lt#l>+eLB7q1d#Cqaj9LdXLm->sq0<%L2G1Rbrge&oD=ub(

g(msm;kov53L3e;p7V&i19rY!?CV}(hFkNO316YK6D=R)iuLqOamjO(pdv?#>*=Ny$)Je?K*n~% z090YrQ9W=>DHw=BC8m&*3{<2AH2wvJ@9I_QHgHp$zkeJXPLLkzz$~?=b4?SP7J|ce~EJ&*|rE0 zjla=PdJ4D#-SO#TxiNLYpICv%pxt>Sc{$BU&C)x4RT;-Dyc%}$LPd( z_m-xoZZq^8AR@c6w^z5CC_kRR%~X|J0@Ee3gRwz6wu^atKnSxO{RL~6n8ro{t|p{ftYEm%A~jgZ0Q1lbA(vP9D0Fr}VOv zgZ+TH9lx;Ex#**Ng~tQlg<%v5925d~!#? zRONvQh-*vBwxn4ioQ|30qP4Ik{_DZZeTenPb+w~^K!KpBu(Jg53uUgX%Y^ny0)^y4 zJ;hDA#e7lB>}pjkr!tHvg1G=NXkq>0Fys@9{8R}5*$omy@oPu41?>JZ}Iq)Leu{Lxv@_%-eHJ7}AN!1Vr(Awh4S^A=@Stsk?qu25t;iN;p--DIyBwm>(>}!L>>Ttx za0_5Q!Ou}4v9CRK3u26d#`xU^PIL@x@dMr0^G7oHW=x7IHG2uw#O?3|4~pzO8m|XT zWN`e#y@EnH5&I+^Va6qd48TWQ208?8W?#jqKO9tZ?5V9#ibP6L`5Y{U?t*W!$@Xr{$fg~Ti>{`#WtZ3 z+qX2qQF@XdOdc^cg}em@ys$2vKvT? zo|O5aSID);5vMp3A%f8ZWJ9HSWt9plQC-2*Y^ z6cD4CFtCvI~tKA3MsqVLg;X}ZU^4nbUc3|yzW0h?kadmssyMo|VoP>p~ zo$dAC1-wr3yzZt4XUxxxrl&@jpKJPPo#jj}kLb#2PNN@` zprjRUIkWu6;zMZk0!fq-&``;b=C#ZmiwC zqnVIF6^x=-_qDe@^qIW>;4}k8 zDt-}kN)sMQDLD31%%20Vs1Wk>r98=kx%FX1FtO`F%n#kyIUe5|OB_FKWZKZ#Up{zG z6oF#fgjDMY6g`^*l6(bKc-<9-2A#>(;~B?V}9bVnE5sPh?CUOOG)gCm8 z^WvI4oK}*`NzeSW0Xt6-|6on;c-uD79p)E#C5AlYm7T*@c97utoj&RtWEnTuJsQSi z{KyxkHoamHJk#iYJZrq@Ke0z6gVbZb+i6wq-BB>1C2&!tqlDMh#zi z0!(53_o?R8hk+29_*i?|wN_h;TflKucUDxMjJOl6C%A)&wX}hr<=TF3g$rXxh&96Jigp6L3ap(9_Y@#phSn8EXlctg2 zatLpPQcK3F1e@cjt#IPa@$Ri-WbwQpf=YK4=zwQ|aUFXnIn@VY8FLn-=kEU=z5V|_ zxK+Z!kYIB0qH{ZWJun?5+mq)8x3RnuV0h@whDm9IC`KHNw>ZFsrD93`NL3q6twMxli`X4wAibYQ|2jlt%f70Dr{K(zo#Ckf zrReNrMHHXTj>=nRLP^DwQ!?*+Rb_t#;+p|1YZ|HZH-2wHAm|AeSL z(b4BVSiWiV1r=}^%5mTYp97=PCI?z_fiB(4o~JEc_Oi6J!VNG@%O}A8^XLfR{{2Pw zYlJd{IVz};C=}(^tx44Aeu<}Gctzn?f)4dOR>P2A6$Q3<%Z!o;x_UAub|-r<#ypfJ zI~p>5Ko`p8kW)9j#hmjo)x%|ZJLWOw$_Q^F{mpg+fEiXAIk;$nRazW!WqKq|9r#+% zzJl5U$Wa1DYw5BgS4l}z)w%V>%`1k0MjzmVBFf`W2fFjOO*jpHInf^N&sG(sXmNk2 z<-H7PdJgcLAUZYk10<$-uE~rB3#kLQUYYN`>JTGJX*h?M>5=|h`4c{GA{^A4 zf;(uT_3{&zRh{&a>NdUqxv9Q`i@4WX&w-qLu`nwyGKe4Ic8Q3HK6eS@0+b8dm6YU$ z&HvezE2!rF+7;hmJ&$fz{&r980hHULNg=#v?gBPv0|a9DJfRR~K_X>A?O=63!<-He z?J4%~Y23|y43@%L8ree5rGBgSyO2_le~X0!Cc)mCg>n^` zFwMy&`7^;B@X(S6#22D`KSixmT>`freXGfGMLC3)X2xuY3XAHoVB&gGUp!{Ah>W+Nd(Gw z0&XiNC}HtkW{mvi3Wu39Qn0trX~Yg8?_}16rZ<(*tAajiGnkF^xq*SLvjDjp2oj1f z%mT~Hzg*tkpIt;JvO}|l2bwKDf_YCzbcBSG1qJ(IZ?O`BgsmO*X|{SO!%i{4=Imq_ zWyiQ2unJ%_aos-ib^%-@RS)jqYR{5uhd_31!(5V7iABZs;clr|VNU3VVBD%0KW3n} zdk%|;p|T-o5tkttp;>HwedJDauYsd8f_d>TG#mdyy-}Q+Cd%M2uvLI>2XKTcaLb^e zWDHYc#knu~=kSMz7cIHb>U^2TmbVUaH0wr z^iCutp~MdjE}-nJdk8OuKV1w6Y)gkGa04LH)!#4g3V045zdY3B;)N&{6U840f`R?t zNZ3DN_ke^A39=JKpc&H!e8GP#*_WY2mLDw$W-gflOa9RL?CQCPEUf+*I%MV<$6dUR zYK#j`U;1n*17OW4y~F~!QA);V!KfWA->P)43FZNQ4R1<3i1z1I#tV!HbC4 zc^xXXNCGZN4R{ZMPwv^MLdnj#pLiJehi?jk*9^CUr0kdg^nRprgz?@2hRag?fkxM` zno89%3^?jA<3~7Y7{yTcLX|Oek6;QSbH)!oQ{>yT$>lnR5q3;!<97ZS@|)F46IhAj zo)Q}IE2xts^Q$Z=MV6C=6E8h~=sNFm^^3LmIy9f?$*tFbHMY@9X(e7B=ZQLXqA$5m zDOh4hHXyqLUUe6qOY5-0t0i!P(|fy90%}Yd4M;qhG8!mT1!3afnfGtpD+`l}j@Hax z{GhUu0phRp5O zRfIVJT0_HU9N~lqdGrJ(=*bjvpXZ=EqktG(AIxo1S-KNmIiE0C);k#c8eSkQK$^|u zYLezH!JD!hU_+}*YhiFBCy2gDD*sXkM}mj`cQ-qrnSz^xHj>bSF>DURNHJt0jAAdW zE*cys2o88ZLn<()hvRKa*WYJx!@0A1lZXfk>4n@VuIFxd=1BOrb_3qT{67K?fn zeqj`Tjk!%wCK&V-J{=htLJMvRo;|r>xW71RehR4eYoZ{k;W8%*N`o54TrS|c0AzIf zSg@%Yz*M*ZE7pR%x36!Yukpz#zuDKC$AZcAZ-T{F7Jd@C!*CjsD=wXNck+l?e!j$Z z@?xwktZcJ5X0dXxu(PtVvSIMA3)p9I%;sIVYU?85EmE>O`Ibt`D041evTU`q%3is( zdWR0L*(Y+`WxebDXFnS;P8Jp@nPWdOhIe@7;?EuWUzV|bS;o5T1D~cE?@x?+C8=(? zqx$EcnD3n-p02RjyWK@QeRR7l?6TU|tIwl_HLjhr{(uNw|E=peMa{(F2eDah$?6+_ zVvl>sjIFgz!}`$e|JFO)Ae(x}5uM+2r@6Jga!;jjo_3zs7^{Z}$gnzuDs$gkm_uRHO=N%=YGq}bO!@G{k}Ti&W;XO?3le^#H7B%`C0xZBi{Kg&G6HL1gb z`w{*2p|5xMz7a~9>$BBB@}sv})=E`ayi$+Fy%Ti-&(b_%YnoG{RRg1U%UcL!r6+t{ z=$0`r-jU||<%)i`d6Ko%*;5ijBB5Vv?LND^b5UKS?T>6}%2G-zmG(QJP_5^XQB&+4 zn6mGDXt`|kqm}PmV>UZf7$g=ytVnfQx*^K?+&hbuC@1;r_n%g;sdDwz4AZM{RUF?* zSFv91k?)hd`|G$(qmIiy{nYb~2jAS!;@fSyYe?kILt4#!S}rh+*4M5OsL82R&$T8D z$X@C5yQ4Ic=*hUHG=A6nX^BIw$-BmQh0=`^Lyd8R)eh&&-Bq0|Zo6uAFR>jiX zR_QQ7W&YMHkKjk?w&@QiQnxngq*qdHuD?H)=`3K*F2}I9s4VGJ&JzX z{!}O5CrsyS?tw;eXH!GR@7%H#ZqYmzOQW3J9G5p~I+_8f*oWSDcyPr2eDRQxd)kS! z6@xvEeMe6j|D%({VbamH^x#*wh}T~7@PsN=q6T(f@EeuIALPfQPBP-0O_EaN;X z65Z1}4+(bFrX21x$gnuzcl-W+uG)um{)iq&yYRgBO}lC%lJ>RUG=J0+Ywa3%|EcAW zNYvrG`f+!umFd+q3YNL@>`zR9u6#K8Lc=j%QRlbb?sYT*&o1#(o^! zq;k_&_x|X_4&KwJpVybWJW-%iAk$%`1Gh!tYdqJ=g#i$eabdJ%3af??hNFcTshqD0`XA|9_8CoI!`G$8jYa_{fz0neu?=MhoE}$I2TTwz67&cI zMwS#R#Fm1uJsO882i8P8tx8PXptCP({Nj@=(eK8NLxT=ae1+}?QF<0FxKzGAZXl-j zrDWhwOsam{#s)dYjyn&{WaCRSeJ(GJY0EhuRWUd>Sf=+-|C4gc0^Oygx5PUijYG?> zEYk1XY@M9-*konBu272k)*tDI{cEJ<{32y$7fH(;jkqVaNPX71mzG=k3oH+8tH@Mn z;yucv>*HxRX6|tQ!%wW;+~h-ip0_cxl=HKCMdNa=A;>qsqVE_&I(oEfi0vn~)@B7j2g&{4gW|fL*j0!`+NLhL zkP-OOxado^eR^1TkV(K5x%D-<;`Q>n^EMqjZWW=wuS@fem8m1OCoR>e&{E35%*$sj z;ltPPb4Tl?%B8e4<0Z`mZ`YbEQNQJLzJYw`PDDJl?)t_f(&t^0WH@$)tBY0)ABao& zW-bx%-0&w>@;LF~rewP(c3)DuD!A`T>uhb2;qm;ZTcKH{a7@Gwu!aP)KCHLql}6`PYS#w0kMg15zJW;*F#O3W2~y>)w?v z{rWhiVx5uG(qkrSdGDTlRHW$IWJxNFudB58i_erh+t9Y<(TIK8q|8%37c4)jerO#=HHj&FQaC}3Oid*tF zgX9FaQU~*`p|%g2#NGPS7NtO zzG?GR*W^v1<-_I)Kd&f7dFt`ntzHW22t3BKuhcXrYtN2;dSf76m3Fk=#8|*AQm%9` z>g?hlCZ8ZMk#*8v5%*xs+O)?0$Ogl>1E%(kPxTFLpPq_KcDvN*t@LWJoY3XAcW*}M zw~6H0xwbUo=mFKv@}NjJ+`!UF#G;(}eKi>qCj**k5My8cVcR7Z+APm)Qi0z&sx5(@w{r|vBsa+gnjDBWUN&46Z;)3c@Ie?<(jQi zO|?7reo03BE?ZjgyuhL(j+R^ZN7)F|rP_pYDhGSi4VWiS1t7-icfC_FTQx&5losB~ z*iThN4?-9-BneT z{+GzEA z*cZ_^_vRay0$s;QR{WwlJz}q(UrhO?6v=JO(A*Wp`FN>)f-Lo z_7$@m1c|ft*Dy?^7w}Zep7C#8PYZgz{z^l0Z%giOhq-DuYECM}6 zajLFCX-~ofn$3a7Et5LHzZ7JK{v=zh6n$6fx{43h8IV?Gi_#3 zGf7IK%N*{Kzd^m3)Nt?7yS>pzTRpB+h*?Es=zP6S=>PcG(c*K(f}oo#{Mht5#d2ORgXoi!{{nEVv+>l|;BKJ)d~9y4NpEdPGoapF>3MW6%xR=uj~Z z>BnVPf#wv8(LtWck5qQO#b{BYvHfz{g7dXGF?+yO8F!hlx)>v&W3$4&@tEbgn+pv$ z8l>-YPqiM4vA+IADHC)UMnXCg66!KNz9M&f{d)VjE$o+>qicTem2gW_|oo9xOT z&02b`wUrjq&=h+&Kh7^sYM$olBCCiaL8sn3+|LLom2G(BM0n15^qPIcy|;J1N)>l$ zUE@iST-m&;$R_-l)cSIl2BX8dIu3OBZVO*_elYsv5q+sJZ~NuqBZG6bj~?4W>@1Wl zcM}kZnAnv=BO?pPUn!*Sw7(}+Aj|S`+ELbYO((HU`(zuEDCdV5jt$PG19!)I-ELUD zKe}$od2a6W&kqOx`-5sue$W*v??UQM?U5}FR1+*%w7`P<(#NF7ardNS1h;J>@9R-F zbI6mvzdh%wL;jxRb;+Tflp37}aR+$LRy=4Z5E6am!vD$TF5DlZuDMl&5_>1_yN{d> zDg>;iYaNbSrK@jA*=x!(Y&gF0ntsYY7wKZwB>NP}xlh9wJ7~eBLj?pva%4^4ce2ek zdThsuMALIz+Z?2izxQp@uNHbC-$y93?|Yv_W(YJClj9Yekj0X*nX*{Pzb%&c%hDjwC@UVJrJ(EG|m^_F9r`^qY*!oznC{h)5SW$u3aVgAYF{_TR+sVDqS z-p+K$QS$!yCX!H<#c*87VVHQL*f@SbVacv*k)92zKEslArcOyYilb)Y>sMTiJNvvl zf59y~h1U{ggAnBDs(0iw-&MwG8W9ZVKt*%PJeht02~y1oIYx{m&!z5+O9mhDAduME z%1}-GKv({Wz1n^r%y#mG_vR+YhIvN^46{7Ku3OL9y{LChTaD!d^AO`Qhc~sf)T52I z`?}IaHc@nrYt0MhaIq z6yJPfqP8a{()nG^aK_t5uIW1lgqXj%tidMl-Ko}jQ4aGOvv+l-JD=Xac~=TWD`$A2 zyO_P1$1B-uVXt1~$+~59?K!_)?S)5Jqz;#<6tyM9noqPf%Y6Cb<2Ck&e~f3%4=>F+ zXrYins3jz6nXT7f5T7Y<#Ytk*^>~w`eHPn~huai+ghl9VT&_!ftrFOIBr8V$TKmcQ ztmjRxIX7|?N`HEoY9akPv%=p=XkCiTp7uIQ>u1T${!T08Q|33>?pRq(n^UqSre@gI zzfs&!B}&RSxM#(>ba}($O;U~X#T^zuUX_XV+G^YMDFe1UWo9*jIgJ=Z_x# zOOyfDXf{q+9uf?Wer4z{Hg zDUnO7w|~9%_FY*PM~~Zsp7rU8`YZGm>57hd58ZAWUud}ZX3N9lDFF?SDKB5C#HiAI z>cgWx4=VCJsMv5jdC&4}hn}cTMJMhKlb={;#`eOpM01(o29CD_otj2tzV}9-Xk}V@ zt5>Y&o5xRce`Y;&r*$Z3x%7Q%ieB7L>{>_Dp|__j_I;5qjvY(Myz=~u_LCktwv0?O z4sMZ;H2dnRt#w`>Xc_b-BL^RjE2R%i&q|jKKhF%Ti?UU>+u`&sTuC#yu=8V$Xzk0o z_<*=1R|iv1U8@`ciJzDX<2Iq~`|F%#Wv+p%9^~^V^28s1a@3jL?ampO|M;$%Mt79g&I+HnH{Z zpSr~-pBOewd>AOGdF}tP_8wqOWL?0pWi5!J^cG<09SqF?0;}{cy^ADtX`w07Rz*6Y z_aE}$wd!O3C=7G$| zSB7Ob`-%6pUw7F~uCMmJyCnmUxt}HUAOm;f1TQM zLB}2%!~)3jKmzhbX1o^Ib!iAVzB;;e3F&GSYc}%3TzG5dcD~N4{v+_)F~EbGRTp&W z&of^6hntwdjkP*K{&gP4pX`BmO*K68CYnR11M|s3$9{?g!f@~3AiV%cU^Y?4Eww!8 zx*^~UuPxxgopJkV!v@z6z}TIVh2=3(AzNgkTlCM1`|h7h^vYekl#~{Y_0FR)Gj2y6 zrEgVuF>4T4(I^WGD^gYlZ;+o;CD*YM%MnIfmam9qWc6cjZGiR=9O(F-??(<{Z`BJ)vlO6MgsVo<)9ml(oJ}h zwvV20%ijYcJRM3l0ekozQJ>c zw|)*?x5EPk3tW%lgqG;P+~Ur@PM9w#W8GPE-*91IHwE_6@{Wr;sL9fWI=Vz;o=~ge zlJ10Ryx!!(LLoe0|6O?J^fKa+0iJ3M>+S=UTW~Em?T{QS7wuzX{idal@3+SwWaJiS z8f_*&r*ug_8EHSDNeVh$7CChwEKUP7wZde2K;teVPFj{RB`P9L2sG3p;>0IMxW+(4 zoE&HXkS`4-aFi%4PJMj(aWi%@7|_s_MP6037`E&XI8gpg`_Y*?VfEA{S4Xc_3)UZ+ zZs2I!(d(qSN!z75x-|K@sOu`OsqDHs2F$jyi=fHkf=Sua;*mGQHj_)P4z8jQQJN&0 zm@Wde`Mg<~Q|SnD#AbY{($3Z3hA4Mpa!hB}|NV#Ir5}bubP=>#T!585Edj3aKmG8x zYb=c~ZQD86j8Bdro64O0%;yO@(Kh3iCtuw_%{{8p(bZkTTj7mmLaOW=f$||z<{ZA! zq~dð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

%9^C|n#^qk%76mJHobqxfRBQNo}RX~I_ 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

^Q;Q#Jngr|e`yij1 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

qxPqV2y;_&0gjK^b*?mkR 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%0AtLogevG}4Aiz8TRX`u_}VJIA6bu958*V3JL zgI#1QFumv42hQT%UOYLYO-F7_qhw?jvOQs<-AJ?{vh z+D85|;sZ-t{!TqMr&=F*<{=s9pzLzxv$mMlEsB654O%<8=IR-+FFML=sOMDicyqcF zh`*eP$ONptnYqoI*ek#6r~XJ$*NF+fq7x-VkPDU_*J~|p5g!v&dDa$gUpa(EQb=(n zyF;~|S4%71UTDDY4C1pZ1~_k6HCn`o@mJVUbGK>Ce&SH1Z;Q+BH0MI0mmiFIU%FbDb zTILXErw_t{BNNx%)as6AiW?N;5`9UR=uw7jbN!ZAilV-o&A59s7P(>yRl6)1OF~F% z8UIp6S5i$aT=24WUm9*AJ7Q{XhS3OqK%us8)gmx~R#Gq6YN(d5*Lfh0Y`$6kSpb6Do8t0O0A z5@!u!6hMt_(r^l#&OMCnkS%mY>GVG9R|xsFm=g0+@??>~ zE5t`eOz_F61h?>1@CG>-jg444%BHrsM44vnBNPOlddIY-ZAr6Op_-0>f^}lx33Flr zL=u846j{p%XKbCO`z8!`<13RrV`9%dJTQjR7>%&TBWj=Wbz)pf%kReeJc@l(K9y}( zJYhI`VHJ;+W1KRNQ&^NgCKQyj5@Xh}LH|BLtnYb4*@F*I{bjOs(pG=S$;JSyo0J3tYWE0Sk|-{=ykd< zxT7B}Qp(L7L*y(5rtgq>_K337uNEKT=%WR)Bul`k!kL6EF;Plyf)*Wn#zxx{?&*p7 zmei|+A;-MW#=bOhCT+(9>4%i)MA~UFxe!0>Otoi$#1XA+>$}zTfnlUh6f?oVAZVwtX1h+42NiDS4sr|}>!|CeHIG_I z@Vr>D=km={RlGQ)fG#kh7DIjD8OQI~t1ymr_6S#7OAK0}E_$D%5c}*cl_>&3L}vFN z)jp2lb)^l@RU*aOJ+w|c%$WKdn&MMpAEI_?`mYr|dNYPG2Joec2AGG4+hA>Up}(22 z0NkC6ju7mmojvu%!<{Ckz3+PfOhIv;k2G;0^J&N!g0{7b*$w+8Yl6-zMR$K|1uqzd zf%vPV2xs#-UXFD;-iOHfIxZ;joV!E{Lk%rhxH^$Xk~>;(+>2>3Z|UtJ@}uYM4EFL& zg88!5sQapQth*XEhf#Tkf@qbWEBK;ELowwWc5(IeyitwR-pn>20!Hi(|9ui zFWQ_Lk5SevCD8Nwd_=wXzCQe|yIpn@rkUfkpR0JTcRv5a(jhTd>sI#DGB7&GK! zP&r<#SprmIsryFvX!k389#Z4+5}BtN*HRL})$9DwA*ea*B#0{#v!et=a)tS% z&U>c%JGOzUGjU|YQ`vb9OH6B5Gfh$H&N0sIek^)5Ez3k^-tO9TJuGzFd=CalW}|G0 zNts|3BvTQ+`XJQTLCM7~Y-4xP&1MBDpoxa6^zAf30;wP}uRS%KODrE|17oD_)ER7! zJ)|s3m+^1$uR*LCvv5tes2W@auc#9>tfB#jQ7T>ts4Wc1LW6uq?=>9u>>D(L723Mv ziXP!0$BVA`fu%(3CA^IPN!#2tNa=#-_h#R$rd~1tPLxIyA0NsZTW%-A{Nl-pPDn_e z3`7xin6^hu?MP3QpG^cti@Ye^5ABBT#0QW2M08cbY_Qr}vXvKKgkcx5%S@z9kHM&q zhKUSEV$=vcqK3y&3g`M7ZNk2;dq+YXGxBT%&C4K0_%a1rfinVZ<^~ZMc0SE%c3JEZ z?}qy1$1M~YO2~+e^ZV(jARn0oB|<^^wK>b3u1g&gPb9`=N_KU7zHJ_YcI7QAnb9O&9vM&_Pl@t1OlQ)owJi&8{q zcC13_62vx!q+UBkpX*&(KRg9&);do$@|9FWWW6+ID6D(YHZ&Jn_|Ci7rc$;y%a9fq z7ecssVPD7zO5a%}uqrr#AAHhfPi^|Np@$$CUG6~VZONQL;BxYQo~D|C(olaP)*)VG zmq(M4g*7PaT8Gb@ZJlCCiAPF#&p4V_K_W>61itaw}{>0JR0f&%-H&f0! z#dK&YG9)KbZ!Ej=lNW(QQ<(GB5%iAPVy z?8amCoy6GkC6r!8TcwyMB;^{VKC{M9L7-?DZycG^0c^BC--zLTGuQP4MfK}MU#ax~ zg<0V^vT2suUO!qCUuV@9%`b=T|HV2&j3Jk|TAufP?YCGPxzqosdB6dE_`piKus7oD zgp6&0(doq1rCUi6JD1LHR5k<~LCw129OCS5R9=G~Ufj^HO*OWedT_lUfGy`UD~3cM zi1c>o`JIeP1;P^RjHxsPRC(k@99f8E-*Ja^NS}Pfi>zJjkq*d6%RtJ8;jKvbuODQ&yCGg>Q0~0%43o+r4)-; z*3CY3@0v_e*Vq9nGoOryD%d7@M`0--Utu;o(GCP9eT2Xkn!ST3N$x}9j9&)9kkB8$ zd@+O_&vg<<99t9^c5qr_7Gff4KXqcN*dP_j*4F9I(b>-u^6IU`R3KrHc_p1RIPGVM zja3~>z>0XLpIGR6S7Bfg6c!eh$Yr|jDIycYb|;1YL#&n6HM70WvEc5VGV^dz+Ig#PuJ!^@3{V`PCs^ik3l(v^;<0VbNNs zUNjq=op`KWgKNVYL=r%h)AhQ6&Ul&~6>*ejYsa>3HCN>&xhIc`!tS!k_rRnH#lhrA zkZLXG?oSYCvx4}k@xBY|11Bz=KE-T)4Z%I4w6_MH+!~zMLaUoWZ-*V{(JbtuuV?5^ z*z{b$RSFU&dm4_zi$QP`JIZagm<6-=F-{Vs)`QCQAo?t)rZooP=_c%6lJsF(1ya0F zYGsZsp8)DLr$8K5mpD6$DlQWCl_oiTg{W*b2mc}jjq;kd?0PZBoqbr%P|m5q1U(eP z)n1oxhO^Gzss}zvU!TxCW0X;?L^7nV+V&G`_YzgA~Xkr;gk-j?%;{r%%KemLW)zIg2eq zF|f{_X5|X?L6g)UYt346Um(+V2bT~Svz2-!bo$#znGSrA*=*qulDcvSD|2dA1_sAI zEy`cTQ4y-dit{koD`^{J<~% zSaj)nPVQ%fy{dAFmifRpLWQ)U)o7a`d zpsikQqR{6~GK}&L)kXr?buAH1dAGnvft0myKA5J_RCkR|!-$r&R1Sbc#4(!d^n3O~ zl1Vdy1FJ&2XF6aq>qN73s;wql&=IdHww&2ZJmVWg!(CDH|&JW(Q)5m=g<-(;0YUOcRA$)FY6K~tSh7-r(%*cnr zHkCFVM@a*Ye|o3KNi%ACq88{h;uc}KT(U@5ZYx`^551(((OZYh?57qPW;wr257MI%m+&@XsidJog%Hj|-#GFgv{+1r8NHTtcx`+=2ykkjQn9BSnm`kYz318qN z;o1g~+S5igJ3k+w%gX1N(Y3qI{V8%^BtDHi`%NJy)oSI*nZWoha2XmvB^xHpfFb0_0pKY*jlTHDabhqz-xp()YR^{IsIQVOx z_$$s#ZoVL_dQjr%_GOZ$-HJJMiP&OgVF`1X!4+P=wH~{ojqPp9F-Dn^ETevTELSn! zcIO&=tydzX-+ynA#jTmEQrpTNC*0cB*)|hr1%YWDg)VwVWuThCihJ4wX&U7NbObNec#&Yf5ixj0ul0p@K%7 zJiOUiCoe-;%wzFj6tH|x^^*NIJevoK7R#I&SMw;;)P`j^Q53o&=~;~LD^x-cFupc; zC9ON}Ej62@#rx?f2zQj(S7mV$yDPUg9cozYKIMxKxW;HxIM{t9-csG1dm zyaI))w)1G4_=@@mUgtIpkuaMLUzqd&fjR zjOJnt*a8bFi`nVu7*LhyAd;%f4l9_04h6Pn|7XD;{qrE5fp{cSzTH?k9?&Oy$7zpI z;o({W(=~Hc%*x_GnbcfLgPyAiP(eNnSJB?|sVo7BJNssyCSlSVHZj21Jpl_+7lIvU zNbGB-Y*Q7gM;=)l#V=6HM}Xsx+$J**rG8r@QZybfligP-8=`(?0A+>Qi1v99H=<@5 zA`{6cE(ShpIGj^W)=mDv5j(t}IJpi{e?;izm(;>{y>Q5B`^r2PD1&pfFUsZKlGp z^m(Rj#St4^G4sF(`Y~!Qx1{L_e>O5gH#d8{#`+2MX{*45z*@RZdU5h-Yww&Ej|n2p zFP_SiULr4H%gH8P=b+XnPPOoi>s*nXH$KH}jgmdlhzrm;crDi$^tB^m?{dULtDKp`|7CGnugPa>kaxyu4Uz;x>(hpv}Us1)Y=V>>W zgB@4uXZoEM7fUB<_=yYo`9k94<>TLisEUfm*HU3y5kq11hZ%Or7J*Le6c}QU@*eeQ z&(ZIO3m4DOg!=I@&QcQoNPu){S11jxCIrGNSLLCVJA6j0N|K(ESY`L6 zh^uQX@OaoF$;Nhq^2X~P@;P|@PjQ742=*lhvhlQ^-=-CrzF+dUK@;F~an2uZy$@Ti zc-WxxLfQdbHoznd6^n0qFq&&Aw@n=unSLs2#%dDKEYMeOw04o648xq;qN^iM3%9?c z{J~{m)#bc&JmNjPy%x(Win>OC!`vyx2&F{9C8NZ6qO^#H@!K?W^4@;;E@KA(Y=I}z z%^7OY(ZQQfdw**un#67j#Kx>8!`ehmUJ8FTgF1uq_K;`p+>+5-n^qt@?6T2`^K8u& z^ahK;#E4lEsf0m@LUkpF&J1>0!DLAH{Je1vy-$uj8JUG3^YAC=*IcsIJ#S$*RefPJ zw53G8l1(be<*k2=`Xkk`VmhYK#g#k0eVgx-kD^4Fhi*hi2hOIsZNb^pNQ|o~TkAdT zMMD${$O){cfkD>6NBJi8rNt=u6Np&y8<HIrLq2 z`Wd~0op1KX2`F&ZhOYsPS49&)5OR@)^p}1d#{@C~Q zTmYv=KoE(b17Lwe*57%00TYYo-xI4`?r!u8R8)+SSGC{nJyL7QYtg44(ie*%Sd!1B zjF8uPi)7Oq37B(r*h`I~!JdA$pw{lZVQfmcZYurB48}1O+gbEm0!+Pz$o9FU&dW-r z5^x%UCcE-EmQ_rf{?RHN!%MsVsrSpfZ#{z&{HGL>jy^&)*zm31+w3oJe~maU7#1ct zDWc$Vn9)b3zWB%>BfLwfI6Uj^81$X4U^ws_Z?^p__jpxz8r}ut79g*9YG*pu+>j!I z3Sn_-@Ar<9{$3=44}+Llx&_5e|B{4um2#~Np1vnTX}VYBn=dkBd!*sbxpHMxak$0J z1NUe7rASTW4_=zkfKB2kgqdXWc`^-Ejk5_Sn&WQlr{4Jl=Uvd>bdBLQsFo|n za*gBxsn*!_yl)Zr^+!voPG=GH<$g*1$xBbk&S_3aH$kC#OtDJc42I<{*NkIu- zP?JK^y(yF-c2GU~l%AwR?_&4)C+Hp02Lh=Ux+*gohf;e)Inhth8C0Y!o?hGEde9h%4O^o z++~F$r4`3pf53cQta_aI_7P(4V=s3}0G*FY3A%gOyrv|G6MXMZk-M1dZ*S5&*lhj;y}j?K_XVH+wM0bL?t zjZ|!gN;;IR9j_Qy;{T#y|+fgX38#;}Xt;OzrY~QQ( zASO%zBewP;-OY#im|va)nY4oYh}vb|Lq3FS`?o`en?1Y5FMTL>?k<_+BXpO{2UGDQ$i>&9)k&)B8@=lNHUXN~a zHQ80^O3Io{G*{NcRQnfK!#*f<(W;LUPHE!*1RWDB58sTD09eB)z|IUni{87%@xE~? zfAzNv5b>JhFr+-;^*wx)H5U{EMV%Vz2t(NE(@3h~>Hy`F6o|Kjrlc8Y{5$$fJicwq$>V_$j|4AlD6Q`F$JRUz z2bY@Re1_8Oid8*@_%F9n{eV|R{4{)Xi)G}(1E?5mUVc@twToaZR2h;CYHFF@9w`o7 z&31P%{A)R!sc8eg2g=M?imTS*@p6bbenWBn;{9NX+!hFudHpKAb_D?a(r0o)0VU~A z;J%VmG4@2O9QvZ+73B#p6|K=2uI4YMQYkOJp@Pz+0jZDK7%N(7=l2^%gV993FVYx_ z&siGZ=aqCkwxtt)Qf&E$@gdP^>$461KdT!z4U5ykM~(ac0dWD{WyV49;;R%02eY#- z#82w_61bi>7B*dxqdh0rwp%{4h9gH58QAcbr)XeTq#bMgFz$l9-eV!r+i~8?IFe_L z{cOD;k4>O6r{#ChBmJ@PSYuv8S*W7| zek@u(ZIHw8B`%w$j)^$q+{nlGmagq1DHbn_-XT#Jc8GpMAz*tdT@)7S+>>$s`y2U% zX17-2pjNk+$Z$?IXXTMEa53nmnN1d|mr;q@an5`v{*FOf{fR+KrFLcm!-uxk$~S|J zGMpRtQ<4nVS9FfJnbJZ-Lb5c!)UVqPciu~mbqL6tgrf$JVrO^_CvORlP{wbJZVr+B|)4OtrqNG-Fwo-u%g=p>=?A>+!nQ*K zaO3#=lyN1rUd1ERD(a(LD^#at=M6a_zsg-(^|HDwPb?(9dRz$Sv~Uhf>+{M!rLV_B zYY?4uZO9HY8tWY*5tPDR+2_5}81oZJsTFzTCVXh-x;h6Mi&dnTdCu9)!RWi?>aTil z8h%h2%ikOCrp442USu%O`mOHj)6+icXhpIQ6jTUb?iqp{;6lbT`%KwWMxstlE1+yJ zaj`jua1u1ndE8wy)U>xW7rAE5rJ_U0CdJN2)e&asi)^*eDO_mdxYBv4crlJ7tmp^2 z;LfROXes>FyZp+0gXdx78X&Rxo>orJ0DSrKP*_2-&Vn}f1P^HBs50Oq-*yu68-;GLDZK-1Hn`k_fs2)yFQOrD|@`&VUZdal>h`DGyzk@oF zi3ZHe_X&jy{Vit%XAYB$Lc+Sm(uRPSlU_5ZOgHTHuB0IDc3m7FJ1%K!ooBRh6isC= zH?NDA7F?j?u69wzQ=upKF+=PMcRq2{Fb@jb&D#a1#m94(4#!&YWKW*8MWUB96VN7u z(4;AlB5j^&&(8hQM87~Z#;i^I;d#E>S5Kp$-*ho>z^rNK;DQ(O)e1Osm<;Uc3lLQp zr-}PW7sJIMr1)I2%eEw{koMZafUO*&35q1?O3omZ;;j7*C8vt9qARXC{2%kq==X%= zC2j1-u1lJS^hjJL*c-ZPh&99Y;7ircsX_3+6)Zk%)H z(t|9|Y%dJZ*D!s_$LASwNCQ$IF-$lnVy7xAKPNv<@dkvQmsZAy56LU4s=T$8hB1BE zmmlxb>lZw`4wnM|1jS^}`%30zH8c8gXOC-Ydiy*bBtE%rL!R4o!xUi!BMWAqe~Ylg zMvov>^_5+SZz@2NKn_5`QGOc@$aE~1DnAGx%RifExm5lM`msR2j%u&PS`a8fZ#b1P z(HuUW&2qZBcIX}BZ^dcp#)RS|10{0kMmaDVF5kha;>#@*=~Rr{42Qwt_ zojX1l=e!~3!8vt^ct zK0U}QgH&wuxpdN+y>c#5$0&cgyuahLR|=&3 zc`trpnBJPEGRc4ua`Ddy`3dqACu9>az4(>`5w)$b&F$|rqd%Jj5i?M8TNXyp4u+%Q zP;!(p;nrZDIgHK#Qch2Zm$8wwXH?PY{Azp)-hhVx!zYy+UY0NaLPDTi z9624mD^G``44~u#Nn~#mgGelDHQ8C%{e|x?!?E`{jS|K8jkj;6$c_kk5*R)h&TUq% zbRV7Dbkuk;HeYL&cFa(J1TD9X15s15M|$99|!eZ22MiuVl(x@fBUuUQ}6LFxX? zZxWEd8om|5%lpd~RCEQf{Nd9Amjt!2JoP(7)o%oWgbUIvkp}}4gBkWeK~oquyaS${ z@(=GMxLXKwn-MGVt9hU-kk_wL!;S1}BOux1UXod-6!h})=O zU`75=TJYGhzV;iy7hP%!vwCbi^Z5yiia3lX#NZW^xR*Q-LC&ja76!-R23ofOVR5S3 z_$kS810M~DYM3$Wh!lMO?tmza%i!e=wxI#FG;G1DmA)QZNYVtbeIV0>-!PE);MW*P z0dV#&GMl7SvV6nB(aJ?PAD;~<$E+q#&IeoLLkApZ8?5emvt~Qho9)R$QMAoR9hctE zV4tG6U~KWVe#v~-6IA_hatOF3x0ys~#r5M)Y^+sl{bxBbHhlPOX%~qBH!2Gs_64p1 z+5K0zv(yfM&7Jw3@AG?ea#D7(AG;a{c%{Be6o(m2Vk;W|1U+KpJskOxnG|d>zvsu9B~cB9=655js!TEPpSoIailY*A^C{SOKAUXuFwx=D_#R=6!ky%ek^lLaZ23 zHvZl%!rmoK_?4wx9e!nYFh>ED*FEITXF0wudk@!ORSFq6PHTVSi3IX;0Fi9k^ox5& z;@kwi?*g|TIxtbyo(|b5F!$#7Z_!LN-?TVqJkKqNaGvoRRnr zt_}X@D^~bCT-3CrzJX{toeRvT?{K0+q!y+zIM=I2{DhFvMY(bttRpY3e{_BKMK#1A zOuW&TTRa>25Xa9I9{`8qqmJCRniYTqO zfUzirOZ&S@Ic^F*Ay@Vy-)a;J&-<_)Miz(>N%A+yrHoTLkHU}bc?@3}u2?W@Q2xdqCV%I+il3m=>WR(*6|`c8 z7}#u80=+MCO~p$B!~;o{%8gf7UyHeHBW;?}$k24hlni3*g5kyHe61y=9jK(%hrvEZ z^)BdIN=SM1I9TDru9IE5=hFkLAH$un)AXPwTU}x-s7{v;PGq_p-&2JGiA@0Q>C3ZC zll&o)Aicnh{YMMp@kJ&AO@7R?!Jx&vqn|h0@~S%LkpuL4U7?Xz-YK>HTO1y4dMs5P zQAeNDQ|V)s7p%N`)_rs?8D>z0Zaj`n)*jM7D+sqIhvXSh^=iS`pj=VN~5{R7*s%`|zD$mMIBTb2CTAtbSC!v4A{ zE;jZlxiF&P)jod+G6sJhIxtTG&N) zWC6fBLN5jKS-!Qr0ZOaQ$M_`a-z>dLu|#Hn;EanB7>TNtL{2MG?k$1C*;1yzKl#+* zYeDAmoo0RD^V}z?R{~oP=$MB42VcYG+px7M$UmMLKS3t_8z|lDr%_U71WzY#xW1dVsF9#j zdw4+!ORVH_tei5yCj0nE*D?IO=pKWa*#6-H`mPA~arFc-O)k7AlXr2YB{6`=iYpbD+AQO|7#Y6ohc+Pl<)EVX-rwcuPFE*F=kozV}SH|_PNnJcn!YkOz zy0}@QkF!1$53kX{(>_1ga(YqK8Zret{BF@#tyKH0Zqg!yr;)aZtp!g&wOeJBlZJQ0 zusFt@bf6dq%r(Yw(XAdL;?BQR7&s>0eVJ`BuGP*T;f7XnSgPei_=@}}_W^*;#&$Aj zgrlyGgMVE!$5D2IzGbyBU$V1y7ZNfTf-_Rd={>~%W08@0e`WFt{`k5aC!SFI5ckfP zCLyCSP|deo?%aHpx)hYSQ{9zjy^UhWsaY=;9%sYj^1x9e|`3`V)jZY_#y>!KA&p zkuFxB>SJ0c0n-ta=3zOsxyJU=y0XU2}u+3p(k#rX1h*DgFvp*yQGw zMTx(b-4Lyw1|~viEU>+P7YJo_T>9}o68V8}ihc9_yezxWVE^vB1uE`(EuUv7IBZ(s zNw`FeX?;tD-xuBPKDpu1kZSM?4fXX$FO?*vMVAakyrz7b`x8WceAJj*6*x~`>jkUi zvK2H~$S3SPkMUBQe8$Hdvm-8o?3+}*CPz6*BRW|dat#>`Nq2%a74Rgko8Oj;c2sCn5YTnge7t7ln!VUl%z$eJeOF!H~q%m zs~;+@v>ASi^a$zh4YhWoA{6gXCuSd!+HnA5B!Yh4@>I`#gXtg!Won$Ff@MD%V{rHQ z&Tbn>W>DJpB<0ybWW;2i)VcQ(#$HT?X4Tr3yGiQ4asE@sPG^2!*1L|rm<6o+1qNST z)f-8p;$_A{A-J!cdOt};)W@&Stpct$yprhO4d;wk8sm!wH~YzmlBvIABYWDMy;<6C zkZy3tcc1i0Xewx19*!#;y(lBiD^)|UZ`g%OqQ7BVd<#U;yf%hq{9pYq;HZIJS=8z5 zQB@yf+I}GC%jmT2a-HFvBgb|YLoxda%3CJ&Ld1WD-zCeT3mCJ}6D6C*i9AME(qiQHS2Ue$jnTfIz|Ep1zKRX0-H zTD_doe>9IRZNjtMm2D`$p-eYXWHC|%C{l%q=(Z70cmh|%tu=}NrpJQu<*}_}-O{h= z{;m?qm#sbJ+YRoFr)V#>4&@9Du-Fhn_*Ta%shkkOCo7WNBwmco#R zAP zvDrMIiRn5f+evpIkD#<>gTEaxoCA@wTXiu>O4Ap>Nk)iYvBAq&OLSK-z@R#8gfG)0 z+BhpwPmTrSHKqqFReOe9WDsd1kgv62hdXU}KjKenMq<(z&spFFJc~8($G z8-Vjc+(#PFaD7-zz0InB4d0zpXBjUIUf1M-Z*Z>MdZesz@bllO+VZGkb zglg2Lug*17lgaK+P&TN%Qo1;!71*j zCn?ALnk|6G<-G?$=K*nuH=6vsOcHly)F~xEvt?J;vSrKBwS&hS^p{a<*N87L)iWRI z!IQyLW@U~G4U)3T_)UfVH+R2}iq5jGMu$Zkc}b>AEzN%N`H}Y%{e;(rh)ZSlof3CU0SwkDH&J9DEbKnR#3c9DJn+xc@b+bq?nsRfJ!!AOHITt=%KS%&uHI z0*65^7jsqoR;F$2s*%Yse@jTwV_f^^`eQTbTvP|*mQ_z#jAiYFn7}DuP!tjdISRhY zq11s(@YBOAa);VDATVPChv2C2$G-XpD?g$xqn;_$i&wSU-<^j1wRV;K&PB7QzH2({ z_(&0DU+2vZ?}xDPpNsfgKEPQN_+LsypT+)9^8ZIJ68KbRxeILnZ*PP9a(JVU<)Zu} z0_G*t@a~KJCtZu1x3W;Ybge!ouSt!&`Ojz*^!aoZUk-9WhUvhBfGBSgf^~^SZ;jG^L-u=-K z+CdPl(i3qWN5id4p30@k;v)<$h7C36gYcOXC?elbaDdhZat{YzI!FsxcyTYww`*Lk zob_&jU9a@&$AKRhU-m7^D+lE*-m7Kn--xdvwW^_qK)U4`Wbz0}B*1~I-~N_t{yzl$ zCbtl=y6^tMhz-H|1sU6^y7w>S*gwxYZvkWL6{JJqz*Aua$=#Hye)s!}`m6rGs{N?C zC7raa|IZCet&I!>;Wp$naA1wIr~)2e0eFYMZeKqQkUbLovjxIm5Vm!HSNBez_?vp= zS2d2bU!}*uNl-W-?4y_b1U+T1|6R-{qttPdtV(HzWsF}@%9z|ECSBVO1;xL z(kgZO?*Np`I=ys9QYW{Mm(KCY`4{c?tH0`Se69j60A2Lgy*bW)w-pJ{F6#7>z=Ob$ zpP+Kf;$JkGhrlN}yKUmO<-eNvPfM4-4s3z`griap35@sS0B``A#R&c z1x`3VE&)dvk5?-#8-MkT;RoQtFRFij__8f)e-|F=)$0ch-Mu_MTl&?*=|4e~rK&%U zem@IgscM=k(|F#DpNdcAaE*C+iyGu|+q$EUAMFf0r^!YyT_5Oa(b-n+-J6y{h zv-h0)%xBJ=IWu#PRWIpl)myWh<$rJDAY2&<)W74G^)%@Sg1d4{i6Q zHUIA9G5JgcC=#_wP5Q}7|6?ZLqVQ7~@ay>hCXhED2p0^{1C(oZH>>|v^xvJg2Zqgn z3B13mD2ERvv@`3Yg5O=_ej$ zwiY;Z0%*+4p@$sfWHQ9u1e*U;-+>4B-gHvX&mAfNiPw5h0(=uQa@VTd^td&YVFNT~^NghE!N4U@% zG0p+1d0@M+(Vo~X-p|o8WlAuL4$?__?E&j)pXEu^htvA5Tu}q1q8@yyK9E1i8Iwj{ z5XHNpen6!hrnt$fZxU2*HGlEj`>g16u_L~6hr<)kwF7WZVm*i&b9)fD&bG+nY|ODR z%EbDihU`S=jee?==9z}_AJ9&9+GTA95*wsT4YIbBZz?vOcuyMbT0o~80tsP($+k3=i=yFQ2) z;gePTphI9#E58?z^ILv;Bjw?zz7%;A9Tmlnt93YmmtqU-uI@m`o+RNPid+Zp{XAPH zJAU~EQ$f@H?Rhs4>v)>sTLxBa3aHA@Y>C5_=f!8Y(lUCPkY6yJ5fgp#eQ#TLXIDOj zC?jV=!D{NTCjP{3q)PVGi&c)^&E8(;itbv8eN_7!G9saur5JQ3QI0;;%lX8G=o-l^ za(_TteZqujgi^Dy4viI=^8|?aENm?Kyh=G$uRh}u+oCwQSG!=LE*^RJmm1}7Ej8^S zU!qA!?3c7$LLUcnw~oFmcQ?XdP%|rY!&jG!iJ!1(OI;D6DTBY5%~cIcF;yiO`Z?f1 zEkR@aljy;88HIPyjSCRh=h!_Z5#8Ynp~PGD7wF>tp?Nr_b?)<>3a@{A)7IlgR;qqu z?54=BT-JirslQXS#;;gO0TaRPa0+28nw^Bacw=TckpV^7W1r+x7A!0&)yW?*7s@JF zKF|0AT9qN=BrXo*g10n~wcGsS3d_{~7O|8bFnCJK+_UR4%AsRxEBG5fDYne|x)L?p zj;Ilei-)AAJ1|zO1(&>WU=1Uvo|V#RGFrjRdB@0rvv1|9fp_wbf3_hvHpU1ob)KBs zxb^j&vNd5@1I~;rnDNHUDWT|x-gk6V>^zR-Skk(0+xjmkv<%y_ErWmZD!xq`ZjaN^V>drv3N9u3kge-8jTG!;J9Irk z-8cpXFrHqnt(oWZ?7P9d@Smxol|6~%LFwf~QDAHe=M-nr?1l2QIeZ|npD&dk3T`4D z>$y(hIZQd4S(iDn7K_cs?t#Ta@^i^E zW|jQRlMkXr{yn0Xx`{USl&nvLSZM1Jgx`_A!{ccr((=5=Z5FhOU;PGo-~Xy9JD!zo zz1q6NI=I?u7^59t+C~us!rn`O3(?et(bgC%-b=#i!y33i*fLBsc!fo$b7BMxt!w%G z7g!`pKNOmM_u+ne&%ckzExfJFV~)u6z@>+ho@57iuE|)0vpMO;fUCdyE|Dkgzlc}; zjf2^RV#|RoHvc(n5QWQ-SXL$Y(J%DfIVxEahss_Tls!xMO)o~$PO3V_bp59(_}H)h z51mp?)H7`G39ppMvv?X)`=0mC2K%lIo+a~kGNrf{@m=BLMv;4tV^4YYgdfkr6b;7w zj|UQbtr9%Wl(dyu^f@sdgZ3{bd<7re@rkiriMU&g zM)XtMLz;+fy7k>gE|3qifDy3yM^zi`RkRc2su3p0S3B`u?8p92|0@!4z)|-&x#WWj zqeEIp9grM5p`7)#e+jG!f*N(e5)6xBtxE-8Lu4Q2Dk>ehMgX|l7!A#JPfrv zO9Q?ANWLJjY5|&dI2&UWS5;8Bec;lQh8CR>j^zPfabfgQ4)HR2GZXm~kIVjPlho!G zFbR37Xena8F!|~|+;B zO=DM8c1Q}Ad1BYvm!F%3HC>Osyz8ggG#<5S>~5&zwD?ZIDo?te;~Yk;iqeOTi~dl9 zN-X#mc{P1k92d>3gjQ10@`CXjPv@1X6&=P?eDAHz;|;5q0Vqb=zFm{UFOoLLgVB!! zq!?EHmbdhPmPs;(eExU?}CY zcX)f^xdKlf*V{|<*aVF?s6)vf!%YaEx4|I1emJsw8~RdiMZ3k zgiudzAyLU?aLVIgg`q|QONbpL!SGe;Nd{=YXY$B!WZ~L%L5eLrP|@EsGR<1K;;7!} z#{$nH{^d=Z>2f9~jfPFz$MM>`aW$dx>&I>Bzyqp52P4?g#4) z%$*a9aOk|D&HknpM_C)>x{*n=uF!Wz1{%ZFJy4ml`@qnmC_s+#T|E7Z0We8yqvEr; ze3yjk^hLW*`ofi;(Pn5~KQKW7OpR&LEz1z;`S3yH=9q?+{m=|$k6}hqfqgg3M267A zfaP&9UDlBb4Z6gV$1X;b0);^n0S1dTeJT`B}-}sC$G+3DxgS zvCE#|XSW18e8t99u-GCZ*Ku9=Mz<|{iNN;2ciG2{f}4Qt>A98p3*F(K8D-&>;ov8& zPQR-AqRy4$-wIonU2AAN^}Y`+kFhkyOkvG!Cr_#|P_+u_f4nA;Jve(jI^uN$#&P>v zkW?5=QlU1$>Ju?!iXuqNo^)zkB0#4D>^D71u*aeYz_cwR3`hqfDpOxJS;}W9ikYPK zf^(i{OTnp5z6zIEpO|<9qR;J5ZIBFkQR1ah2cWGDt0X9FT1FHwHn%3ky_w4h65My7 zMWZc5AJnovS5oWW3k0O(mFVb{YeG- ztm?R%4JB{So}N?XejOw}nV7J8$<7Q(`gO|54Y&(mmwT*{sL!dz=q%_3If~d__dll% zvFM$-OD9ZjpWDNUN`hu!Voob3nxzD{9DE*%d=Sh0BxzI08W;(4;bRiF7VJ53T)mt_ zG0sT_HKJuJ;aAB)Oo$mWFHp%}q4Wvja#gs)uDD=hn*^@;UBALtWqX|8%8QZ-rTgLl zDrALE?kL8DE!hIv;6T2d1iCJNG|gQDX@+Ta67dVd~v)Ac}9vH~JnlPn75>YUU# zFMmgeb)B+L^y@Gig?9%I!5gGN$o`vRZ4M!P=;rJd#}s+%_@G~jE`mX7p2k2Q8n zM6Ze+{KC+SUEN7fAOKOD6Q4r#F=6-5A6^ zgNm6^_{f`#hL340Isd&MCt998cQA;tFF^ATD4-E=ez!U1dANjU!P8g9@t81WwveH$ zz@Exn{Gi*emt(Y)|13A!c>+rY{8PenNR*65NF0eZ8g^eJu-=)gIsyW5?Npu64%<}3 zwYB`wfpzTx-WN&t8vj)i*AHp_0Zr^B&pT&z>CA|ClkC&EkH1EN#gBh3<<%Xww34F8 z86{^nXXRj{lkjN{dBcfeXrd-^xyF#@V9vCI7!Br0A1UsaeIvd6UFV-hO4zD7pA7g1 z{%RGVUqKz(`MhM>^>*ItPByD8PHd2Q0jz{G5Im?SQ4dN#^qL~Wtk=OareZZZ%) zH!w|>^A5K6hUI*O8;&aK7-ZqLLPl%piT=*ewE@fEP@BiSu~G%Gu6|LgEwS*{Ty1Jy z;0V|V%SKFuk+<_g(YD?+Sik?*l9Xld?T1|9`1i|skGX>{lshT%?P-31$|AKOk`*G%vX6955c^R|e0nDr*g z@zSFs(1JF>7~?ctbSNc5mM+^q3treIJb+7eDwtvxkJaqUvLpp@=QmUs-;F+4HXQkD zM}6=?h$X2!j=^Bo_H%#=^RrBppa3rE6yo*e+8X)27GHl3A9t}|#2jP{#n%1AXoI=G z!IUY+hFy)S^!;JouOXrt8~JwK$ks1#@FT(ZRnr2U#~R`ZFSXsRXB1v^5^Vy5#wtED_b$@p0V-+F zIxHn{p=%IVJ!TXy>K{;wd>>gjFw9?mH$zQRrq=21sWM=~yef1gxFAOwG3J4X1frf^Th5~#*Xdo022{9A1ki49pji7g_Zr16Ub#yDd zfBti3N#CFMAZ!#=6qHBAM{fI~JjQ1K%pJn7QvMzMGaI~e&E+vU3%NWr){?3RR^kBu zFeVR=aC3`Hn=kODq3@dYpKAUyUj63*|9^<2RA1rTeQ8*ba$P&SA6=m&N$Rx1z|1TYUCTU*^QnTN;d zt=_pPB9(ty!BYU{r<~p8aeD3BdjDqofVMh(kpHCp-3l*cVzH-Vnf}cXYSfQEplGHN zPDjgEcVdOP&oUY@9psF4MJoUADGxcEcW!kNQI3{r-OzBNz_&L%#cQ;$H)iaw z-J%@*S3UdW`|9NwBbMu`Xln}S3zFhn<_2pr57p8q}YYzbiR!M2h zr!U5vB$ps&yKXWallIGVL18d6W`o$Hmp#uE0N1yD7)0`I6#jGy>K%NsiDvrs2x&VyAJRTA)EWxRGEH9L zbfFvmaGQMZd}zh9Z_OmL_TdV=;>EKtbxS^`wXP7gTLYrSaDYW(EH(;+rJtNqj5vMu zRGpJ5XU-X!B?-3LbW+sg#T}`Kp>D}_1CByd94h~QWSQ3To&XC;iHr_|W>iV(LqYy> z-Bl=)Tn(gE2N5ivz!>2K-cP*`GSF+4>iRZB4oGQBB^86d4KTfTop7j_pVnlJ7WL*a zX6=$fpU-X7z$vp9Ei|cUTYJBFl6y@=J!Vh7@k>Xmbu<>QUr9Y0^WA=8fa(%H7O6|` zJX8Z)Y8u~#KOk=sc~WKUjlJnrJUzcU=nF-mKNS&;)#^unIG|R(;!Y2wLaT)fxAgU` zC}-S13%$XjDi=5En*a6go4SG8F)CtwUQArxO^bgnw4=QIK>s#>hs!(czU$z4`}F($y`V_bLLoxz>Q$v=!Vdu<_PU4yjzq*Xg=ujU$vJlAo1yg7+qf|0Op-k37 z=fY|tNptk+0 zOzWEw@Z|0*RcAx~t1MmLI@LMnBBxwTT@}V-@>(RU#?ZYuuy9BF65(|nX|flq7GBCmtN(?uiA(q-`6VN(0k8Mc;G_* zxo(}UT#cgg`09fu<}#c3NOY&_)Vlg~gDa<+qpa1b9IvfA=9+gH&CoVR&Vn1Q*@Dnd zJ?|XYA&VGwvzY{fg7{~@822$*F{ObT`oyHWF{c`blc1Waf7L}d9p;j%N@nlKUESP} z;g%?UCu=(y$0&*H@sP%6gxv804tZ(3T?Zr~TgN5+xpN1@CDX3PL{>oEcf@es+o@y5 zpZmd=be5Ihul7<3a|y0PCu%l}NRIFvR-Cni+w3ZynQF~m+nU)UX_)Z)Rr3!E`G7b1%u{r43qYu$_7+c!OkB1vCvuRX=T?L9#=JsX=ybJEk)*BvGUsl_80GMyA%dRZ|~fNzEsZ?@`<~ z7o?_fz}mpxw~r2xuQiqw%!yw;t7M{CTA_yJtm|dleL@SHPsr}oH4Ta@zp7&mnLE_gnYFb)LeD*u4@>7q5oIb|* zx%h1Hzd$t}vnX{(es4C4^QNO5LM&Bu_e{SH!-F$=l6_IA`aMl5P*jN5wpk)~(#sP+ z(t=k*xzXrDcft_g>QltAo$-6};;sm>^yW>?&RZs3d}1Ys$JnJ9!+p+iT@zS{fw5>$ zri%BA`kOgC9%F6U=33SgQ5e(&)fJ~+v7bz00uG~bv>f8-RW`Mj&@sq9HW4VjRp~+` znc;)DzPn=wd|q-RFr}h@B>^V2W*P?JGHvL0)LNX-G7y7E+>}SZrDAuMZ8}$=mU}?p zBO<*eA2P)b@f0$QI$Z~tNcLo-!q>T`KbJa*G4r`lel%QG*g`EJz))|sfA=a@m*M61 zV-qgy^4#-3pp&Yt?-0dE-3vzTvHc>#Ta@x&)!RLvKh1lG3rAR$BO2!e`D5iHZLwhQI0#;qY3PrF-iBn#%oxgPInpmZp zn7>!0Sf-w-5fz2NRp55d7LYkZ=t!b(%nyf1e&m?uBss9;F3UO3BZ_7?3CTaCOr?nt z=t9Nqk6DtPuY)^78ZwO&BO>{YYT-5^$Vni#ctV(^&`_>oNhCbBo>k{x4!~!Yc zCfMZHxS{%61nY`7QT1sY_Yp%{K#iN#wkJ^RK3%v%#-8>`Y-}RE+aD08Z|pnd8L$8( zE$j`1$Z~tc_fGUYs5QfK%OfiR>&At3$>2B$De=ti+5H*1YBm)XsjPW)E^y}GKa^p- z>0lELKuFLIfmlXTL#-o6Qo&oFr$Sz$@d9f?=MjiRp~Kb4u_=i$;9?i!`2#{>iPz>t zz_d_Wfp=`~;c5uhjr-W=5dmG>L1qBLX;0}Sh7a*!Rf=SHg7)xjsv21|X34(&f*RMS zY%%OSAd>bfKYW^M7)fAMJKw?A6TWXYb#+l*f*9?=R_{_%eI#VPGl%xyNRtp$QHy2v zih@YRtMo)a6Lp%oq0cY&uD$9vbIY7}SB968xH*JjJWneyjq$|h>E#>MX~p)YS56#g zPKW^%?=nP#o6ie(>SOro|jGs65B$yyZ8$KqIVTT@nhcwMGGptHB6i`S?ON&E1b4!$=} zStLL^+#@73!D`}^D0oIStCeja{9wf_f%bB%hTpOCkUh{?wYJRp@<)Qd08_^?IJTWw zg9U@5vQQnvNd5{ljQf;hw3}9w;-E%8Dq;F<0O4F)TQsPuYwyM@bW*#bQvES5`qg}y z0r{t0ly53aav9EDD7aj;wHd*YLizkdBH`t3V%DAkiGJ)J;Eyj@k1+A=a{G^xXE->O zFIHdG9azX8H!BeIcWFQB1ED&zLR~bYoC;Q4Su&NY^ym_61!l&*xKV7*n4I6S=`~JU zHx)!_NtkFrxjqFLyBV^-p}>e%OAk;I=tjIj#r$vq+PA)%6CL78A`T8+4dNjS5g{B) z0Rm`=wTLdDbCbd#?Pc_DKZp8H_hE{=L-R_PirxlNylJ-h;(1&Bpa9+8OTq*u1Uf1EEPUTwm3tNz;eQTfbkT%1q3? zyTXYDe+d~x_05oYaTbdyG^N@gDD1NpIf4(~vtelI8!85lu}36}{~iO8i<@h+Qhg<4 zZK9JUkGy`J0RkBf3#)+@ACSZDsdll^Sx4A|q`)Q}S|PX{eg;$poLx+GH4cPC-rC%{ zoM8ic8DOqn&@Ps)NkDT;1zhmrESpkj3b@6A33M^`!>M-rvJa9o0msJn>G*a)@1nV7 z&z!t+W1`UbcIj60Rs=|Xw{*9Bx9&iK0Y`MVVYf23_`p5)TQ=aXA&}UBWDoo&2hPKQ zgbyS_#_KiXi)9;j=aa=Xvd_eFP}+C&;z<=;d*;r!YXMF+FL!b?EP)*=mN|>$Xg*Ps)VibzCg9SDq~FNa&Y^r_z%}G#*m^o;$;Z}cJn7Q?!BsJ1oAntM>ZBP5mi(yWQ`723CchhYSS z57}zF*$M=BgF}&ga61SLiG<+8=peA=3Vw`(8%epz_hlbwCRMr$#oLLiN=Zo?B({D! zWOu~jtQLwuF%e={74j9%=&k{6mzA+6yo%AHDf(@db|ndE995Mo^Xca$H6-o4VvhQ& zWn$mh{n70Z_gMMDoV@T9^KkF$6X1PgHd}2c5&Bz`2ul9peuwo}2r7&dNnZ;4vl5;=Ic`tw^S^@QE#+P}aAcKjh2m8F{Z7=ZVrrx%b z`83J^Gd>3jU{c?o0p+&Z-G4a@=Yu!<>OUe9AK`m*=mrSBGvV2O3kAGLItu+$otfNG02!hQ4MWs&1emtyIe8V{uP7 z^NU|sB3_~3F(y>yq-TyrfH!wToFtbD4sT?w8Lz;9Umu{sG-v}r13=e3F7PN}y;PJC zh;lwga4Y=F=X5cyZ}O1SWI1E8Cv(wAjRW^URFU`mWwqwvRkw}^dE0_K3xyi*swvXK z1FAeyX>^M_o0PWSn?>J@=wO-zj(eU(%vt?w6WgPjJ9gOr%D;2!jeItQY}*}yPy+mi zL4l%yI{3$<`wOiv_Qy3$9TbS9Y%%D4Y1*HD=1A^La09hXzR}&XKel+{&jOt1PlTL8l>%T-d|Rks zRJI4Ok-P}LBkHZ*JEp=I02&K~UpXHlcwajZZa{W?_O|;7Py^K4UzQ;RI8Fh8JS0(j z7ON}LplnaiXH&1-D%?4tq~@`b4P}KwL#$gyJ0S-H`ciVyW}`gDzo)@jR2nN!!y65i z5*5%%$8+m@5-Jt&aum2~giHzw@k7jcaOL9!(BqOiJBs3RO`&i755mA<%1#_xr6R)5AkzIMUr zY512NX=OmD08Kh(I28s4z|{&j^*M#M0+P_(Rz$#+=i43^Z#$NFHyvH}J-Xt_(CST% z6D__(C_gT1Ja!LQ6!-%&3>VT9jZ4}v@9hjw+}EQkX^+B@58jYY!r>xuB}C~}YTO!_ zK>2i=Fv4z)VO_HTIX%HFxjp-6I%>G|PK<@4oDsQa6CGNtPEF6EdsjY`#y z^5cf>%4Uyp8k`{hWfvUR)u<5IO6h(PSUf4(r%ZiAzY0BioSTx#jX#5XibmmlsNPE1 zaFL2ZO6OVs*a>54%g{k;GO}){`&s)K zwQxo)D!PtxsR})onRvi$a)G8YP(?MCV~U!DSXHlA1uOvo>W)y`?otf=Oeo{a@7cb9 zfn7lA0X(sz!bpWd{0F_74h=2-)k75n|SWbhx;ZwE7RLBG5XZt#E)zfBOi4 zNqwH|Jw&~20N*gbhPtR`F26(j6t9;%wIPj)o4z7odyuBJF^IYwn|&`ukwZ|T2WIlrbnP1c}gu0W_4 zrlf_ZT5K%tso=Dz(iW7lM9rFpMb+PqHx!#{1mj{(LXz1!2vb3wIm!tEDRh5yp;)>EtinGX?Cy86HxbZjZ zkQT2@Z#F|Yo$X-6D}et{I_=pmbdb#iZCq6>jYd9BYz95&?B=d7z0GpLU1YES6>t#( zVMjn9cn}GWSLkq>%L)ADEsH~7mr8xkadqbem6`BSvIq}8Hm?KH0arA8Dr+q4zMiq5 zzsc-<<1Poh;t*v7TSU4w{I?VG?zKtl1w2g5{e^ujAF&J64sdAPa;zJ%5nQQWTs6aM zKL)y%^;o2`C=iTkYI@C*#0{}@K?e`Luy_luyEFEFkN%?5Kv3Z!s4#_q1^})5D66}` z_R)wtrL}G3h=zq2?^b6@XG&$M`MrmCl3d_GXUf?J?3(#Y4}%Cs0m-)0XZ6e1KD zHI6~L#*bgxWT$8E3Ho%WcOIrjif=@abwJxr0hI`l`+UZH7L<=#!@b&U_272ZWHOJp zbVy|#B}FAGoJr5EfLhzp-si!3`#6kNaK1sWLWHGl(*rWi0`H4CsKBoyDAgx233ltU zY+$XIejJ2WP88$D)Rsn~3HMeRc1i}GHc+!7Cq2;*VCtR%Izr^3W+ckSqoFMbKJ;E2 zq6CygcUy~Z*PmP!wtdx%m_tI?)KS??gK6(IB)mk^MlnSN=5+2ZT9IPbA{HXbhBhjt zY9AHiL$(ml?;<5-#JpUIVZ1IlJQOlp&oeDm0Wnn2Kd}RsdP!C*bVC%}d?u25uXp5A z(U)zu+Qsf)Pi$8EL6M@L(;H<+;7hT+Jea*mA)ml|gf{SfY!)KJbrD6bqfV>+R2a$X zXh|7S>zp?M3%-}_7ObZcWSCldTyMf{;A3bjsgihV*-JA`^5+70E_tG1Hk&q-L= zJFKmzKLccQDqWK*TD`YaHse@e#sv}qu_OfiW2ORnkd01&LZZg}cN_w+E-D{Xlr%WK zUdLrPqXSs*psV@^#NCh8+idX~nKOhBNb_d;`|hLVME|xld|BhAcYQ?*$-hhsB%Kceq}yZTE}t;{0RFJwEPx4}f+3Hf7EtHp^<) z%6@Z4gwGdF?PBR!|7lZ&t#klflSq^BAb+At{3a!3dc2uoDrl26&le1)%R;3IocDl2Wrjpj z*NW7m{K`F&P%pF~>;DKW_)Fgv;n`gv>DF*M6(tN%rtU!`Opk4|AaafNu7{`?`Z-9* z7JwE4+-kd%PIn_T6c8uMqez(3qa`)b@8E4VW&TP^Y}l(|9lXIrvOXld`$GtlCq$TJ zPFJ$zsr@UQwL&+6Cka!Xna$ajhC8`P*)iF+ZB;C3dceOUz0Lz|MjpL4z9WjV49kLc zzIQvo4iM%NlF(hLm)wg5cW8jhI|3-yR8+KoHBLCvp@D@Z`EkL)`#vekkNGi^h?q^F z1G?w$crm~{1l@#f8Kymw^;lktOr^GIKA%Q|fJvRwjy;oa&jdp^A}PLt8k?Nh?I~P> z#KNbhq2G)dPYq#bLdf*0&_Ii+UW0mATXRh=i4@xq+SE^Ly(sGAuCQ9)fVYvpuuT&O zS{(Ua|L_6OE}l+~;0KHi*84n@%-ED3{k3k36v_uEY3)Z)5u*p>%`IkgfjSegn)rYL z9LyA8oO$W{7Izk)(VPNW zPRuYO;Zv8)JsbJH4!H2UP?}An+cokKU#JPk$&-F2@&Y172OWqM zeboF*SOS)s-IeFXsV6(2$3XDmuK&g~@x##;sfNm5`z(04oC%UkP+9?*AA7H_Zfyd3 z&+rR8hI=4>>gYp1*+w1scmkSnbw{U376tnE`YER{Jnq)$)oBr3-a4~blIb!n+o~7j z%6_7(NB->3vE#|p9P2t9E1KNiKr5A6bu~Q|<%BNds!R&5vr)|DVm3t;u^TA6=2^!9 zG22+|AoX%iN|A9e5OI!7$D_jN2W$`k61`!5C4yoJd(rZGwj(eYr6eVUmkJa1;m0T- zXnn%SD7O$Nj3?z)sa^5T?4M~Mgxe^3RB$5U`XI}E40$vzcCcexr^;MkH<3`4+93uw z_Ge)$cf44?JT91EbH9#?{S}TwPjp}s3wXIc-*;1JyBaR&M6F$Xjgqn5GdmoB;4f;0 zLz_u~!5$qI1)vi`kqt125F8G@9~qkl!Ax6^Gwz|bMJfV=tmq?Xs#+Fdp@z}UPrk21 z^s9rLpYBCZq6}f25NJ8L#c+w{n(TV4dDF)N3 zV}qa&HVz~mhy52V&kF!rzVr-V!K{WJM(R3x&Y7pNo{B9q{KKKpE%V~6+0V(ZWc zEf2rWqsl18R#j3a}K_`q?JBWfjJd1bcf+VuYcqj1UaFaN<*O3btROj-=9AYRgoSuom#GF#)#uTG&hb^ zOd|?Fg)Cc3zj}|k#)Bq$xGGL8yuFsZhONh=y!#YOMPIOsltuR+Igk1@BV{+jGy%O7 zIy{_e3K)~_8(YjM24G)G(ZU!|AkBD~-oxw9s%V{0>G9j{zK6P@)QjyP`r(#^25(#; z3f3@*X=2XvAh&)@tcYVf)Y8K6$$))X#5T%pAKLIKVUI1bvN~P7{x;n>##1$rl+qy`1kTd zpV~HCG82Rh{i)xIa;%}{Vky~u6HAVuoWX8>pN59nLWjKINlKM&p-ECAO(WK_uFJ$- z>PVXJyZr7tpHlGh1zrX{Z|n{F=*7pa-;C#cy>xWoR=fKF6g4nn=&&7;5OUb$6~Wn% zf0F0+RJ>*I%aCdNKfHzqSgQ*a;rr7&dMUe+W=|<%!)pc=@C+-Gh8J2AEteWb?OH`t z4U^Duv63T_xE9Ssdv{UjE2^GXz6|3ZKPv$0{+`*Q8v-qW742`IVGE2WoA63=9Ret0=D_vYBJj&TH&cc}yu&wr!_efoua0WL%1GoD^Ms%x)QttUZ6Ye~E zVX^%ipAxVl8e7tgVoF%z{p+Lvin{o;EiY|J#QYoxB+0_Wgzkrq{(Y{jdU`)&%~)+I z{L?gTb7`_MnHHP{I~yAmu`9LMS`0b5hnRTyF)L-14D<1swAn094e+Tu^PLE>wHp#0 zF~;k{jAi_q2xYp7O6c`6HhX3h0euwN+CO}6p#jXN-GJ<}6G3yd*zXta_RkaM3T;`v z7gpZn-Qe^&Y^h)^P=;iAF>6u^h#eo4w&|^fuVLj958_qD2j2xuAjY_`;FDeGOh{2|H^jFi+%1zq$Znp;n(b%uPkdi`Qevv_Oec z#dumYO%^bLP}0XzK@qMSmy~L?z;ED7Q*%O$1305AMz&m|EQ9N`#|dQtoiu(pUw>;j z6>b{D2bf-LM=6DP+H8xIVM{d$^L8)qMbGH5k|@?t6(7+?VH+B+sVDn0nTZjQaDnra zXu39-Ge;G>7@sT^3i}*Ky8%>cj)&eHk7V={(=^M52aR)lz+}5y!eA_el!*X@?_rc; ztuW1K9AIAQrIe81rnDNBJwc6OS zpNZ4ryAKR(nt#MlmE#lat$#?|Mr1Y(xqw*W+$0KkAHR_v^qc=B|A9@J^=+9u0Xue* zlCbwe>-Z#suqTzUW_-k#EUQgI0x*y|3=i85k8L9P0P%}#+nXfh^GP`=-e2!!|M^7% zD3is^a2VBn+HGz$f_8aB*NIQ-Y(OK8dx6}FgNLguKu*pSLw~|$Q?WKx9L^KZ5pHdS z?dm29kqkP3ZhoGm1N*OA`>W_VHwU>V1M;2D*piCQSg0Qk^OR`93EzHSy_$4_ z5|A*F;RDHdABDw?!`(3li~d@jFVnp{0C+KUv7MXc2yUNv?sri_Ng8Z0`y6KOA8oiqf$+4pmXpW8 zztFI4mN4fBB2B;E{ZG24^PADO%V3lflJY&gS8u;-OBqW_rVGsB1G+#p(81ptq-Pqb zQW4rAF44%UAJzgL$0Nh-3Jtfmv8}CJ{Dav#z#5&gEOGGAjTM1NUVI_<-+6vDq(dN_ z+V)a||9P}d!Eic66|Gv9A>_Y{`*)2{%9lI)dGVCJ&5Pn!*O1$LyXvlu`@5wFZlbaK zE&YX)Iy!)%Ls*i|vSNHl*f)8Bx_h)>Yg4Fj_X~z2(x8`>7n6^bJFyv{PZBB==OCYs zn(ElZ2B&)rPe-G!Pd^)uJ@j4(OIKw@EIpO5NtPTsz+c~MhIi$_R4q4Eg=J>exVG*Z0n?;rb~ zz@ZPk1wBECQ5wBC@V0y9><;j7T&XueA^pH~I8+sl8z7SCFMs7#=K&5YVqr>jhx+Zn zg|mybGyhXP**k)8yF{UhOW^ub!pzGTrbqXUQgrgtYroj<1#2uYZ~mW~Q#&@%bfROb zloGVt4ajz=9}KAT-0`3dnmxF%ZvxUcZ)0vBD9Mf&k$W&UD6~Y~jlc(6&)6kOWM@+` z*ibicO3+^Z{n40;8@_0Q)C6?LMsg6U?Je1#C=U#+s9}=8oFa94$&|e}kewAmmJJj( zV{7~5b2!!gYrZ4nZ%GG=`$FnVN9f=dM2|(!IpyFTG9Wmm4VN-lj>x!&m?G4V&hLc~ zu&e}=I#oO8zu$GQ#oIe%o0qWUe_v@szE!jVCY748@P<-_-j)J*OgG9coC4cZ2U%GF zBn4Y(9j@B?RRv(;Gv{72-V?EuPY07sr)w=%#5lSFpv#*%^W9xY%0N1{*M>ld`BMA7 z8iBP3@IAr)VnssccK~!&Oqibm&+)$uS51(t$21mw-rCdy7)n^P(m4VffFIwZJTmTP z$VaF@ac)ZIYIaJMHeo;$BJ*G?jVesM#jqtGq~Oumu2d1=u#o-9xP7;Mn<)?`5r}_tf8sO#@AUqp4fglW zeGo+{x4-v+aG3X(|NX?~U(Wm6e{R@?N>$h!t*-?&#*ZdA)v+s0nD8$zCq>;kEU6b7i&z{-xRH}yxZyFM*cNy5y6w%*) zo>6!R&Q_tu6pAALQR7qF$uX8f{=AJ;NffiMSxJJc=VvS`ru2|t)2etTDgylKwaM2O zIm}UlmigR!7fr%0Uf(b%l5+rW_p}~lMtED~G1c0Y=THeoc^|}xv1xJ@Q?=&6h<-O6pT<`*9w zQAsylwWfVYZ7ciy`vr=qcVQ)%KX=|w8{J^MnSc4=52(w?I%U277=0hoY_P$*94L|t8KI-1tw`)yq z@j4`aO-3@VQFzQp_Vy=-dX?kqt*!oU@#9GsrIwpnqj~g+zTJ7FxjzN<6}qv7g>VPv z-F|l1yol1}Lev6aP5(QxLw?M7Zx}5nxVkyr0*AxZhc(xt?6k-4N=O=fHbhA)cPG{O z0XGj0f}qvGn|U02rOxg%K*Hq_`Zk1IP-%U*Vd4BGL8a%T>heP+MJH08goJfAK-A_c zw{|UKg8k%9K_W%6HlVWqfn1rZV~!E;Q(%RH#zSQP;C@=09Y@ct#z$kSkiV25h?mvQ z$sC4<`p-m}$s7FFMc8cifBah3>!0mDFY^lzT-!4P86tYTUken`GQOwGUv>O*%sgS2KQlKcpmKIP;D?Qhzp(6gwL~4 zu#y*34YBlZ74Rb;RE8I1&Gr&Jq2ujAAx;Sxz8~C8=(U-SLGJ=qWU?Brg?w zf$7-Lj+?}YDCL`^lrsX74Hqk7z-d$CG!C8@C4)Q-`qqP;5bSwllKAhzO2W(iY4jR% z6C{CYWNFRLh5L}6krkZ#YVg?*VQaTu+vdE&kBpKs zo@DpBC6kvjZBIWJG3yWd)DdASd10Zt{d_W-g@HFC(!88v+|^={K+uI}_uC!-SoY0d z4qh`$0<(Iu?;{l7Dm)7qCw(QN)$;Vi)~P_C%?CMUjkz*b%DkPozuhy5XjOKzen8Fo z_I9ruK05dvzwOvyL2(zk#ABF`;@N8hAz6VnvB~9uMUyvxs*pv?g)g`2GeS8VTx(B% zs&?RAw`tr~wQ%=R%RkBLsh>!cV7_R10yNBlCAv94M1#Kd6%0 z-BUS?xR&&#_bH+d0}7~i(Xm&h-T!~8Rs4nCCaQ(Ahy`U7CY)4VTYlx_Q%EFRiA1>92a(CKi00qxZk*Wb!pomnmhjUjzN(h2=B)JBYwh;9d(cr< zKaFgO7ZXfj#XPlj6U(O~3wWr>Gn0@t|M;9_-m#R2Q3he`992`AlYFPv)zHt1?6=iy^DkPVd`?Dx!V(5mtKC3Rp!xWw$ukvAv zB3Z4U#Jk?%FSl}wm{X=R+`}tfzfJ2O7v}ckk8yAm&_3U}s=y5e!yJ)@YN=J0P|hdp zI=%&gDyd4+PgXw18U`tj9!wFV2`EUB$&m2BH}B}+z9k;N1N6d6uzzug%;m_V=f{tj zKUI;(Z}E#tkOC4m$4r6#{pORjL}FCj6TA}P1M5C|&JJslrvm#u;CmYc+vDifC>nGj z9FG}!zP*;^in-3roS8W>bLJfWz?6qgiS`ZF zUnq?Hh}>&g+ApW)#d~(Cc}AuNRN(9<_1e(6_*uQijb$rkR^gBcZeSqDVK7DDze@T$ zP`{!sX&e`hiM*7>%o4PDOFZ^fPbI%8a?_uZK;hX8W3!I5Yz^116|@G3njg?kEL_>> z7BJu3$?M$GjDh0WlHwIK#|K5mgjD>>6{N2Rw7+2Q_BjWHL!W(3$dz`}MOR>s?Id82 z?mxheVpJ`!iOM@ht;Xa14N6MiYw-9#3c{F}psx7>j)N`%%ZN^%5HgQmaX#e)j&v=W z4cm#$q8(9s;@1aIYtl?|TJ~ANm-&9-=W*&jNvJ#{G#FS+nt)X@~W=c;*s2YGS8* z%PoM_^F>^Drb}I51?}4LJN{4Cgr_C76jb=tR7kN#GcR@{8eH&`MqT@PP@=2pDw%fy zS|}MqsXey;qa?bs`pp)hj0>trH?XiJ6eb{)go_?=JX8syChE|xJsVCE=h;rC_Oltr zKF5w~dz|n*s>>|r;z=%pdHM9vUPgKv(@?WaQQ%kzAPKkg^d!${l>WuYqD7PK`;Qr6 zxe91$$G&W3s8=t3I-#yMe?#;~HLuwCL<~LXa>7f`T!pd3a6XsY@&&L9RqunhjFq1t ztfNC^_s%L}Am|A`jHbT!Ysk*~fw1YgZ1NS>Vbv8z}2x^Ps92yZ|E?RTM_fyznEaues_26t%N`lMG z$b59YB)S^P8u{Cm$B3DEl&82Zo0xm%m9pjwYj{4r?3|}j<8oa~SzVSDlY`nO>n(!n zWS{UYb?KcxkE2K$y3AAM^u7}qf53u!<=s&}}o=TCBeAbVD zsKb{zunahg%DTeJ@gwDh^v4mJSO!^arpf3)Rq9l#VzQ?Ol%@SRqp!j;d1;aYos)B6 zklTmyT+!u)5Rn}*e~FHH*ZL>C;1YF@wLA_?=2_u3QO+-X6PYIwT6=RCz-rIyVxXw=|m z!tylkLln7Ofm4x~CjRZ#r$5Xc)Jxhb2~&J>NAid11zFM$9utuUkrYmEgxg31vV!Ix zGzG^gg7X>vG)!+?=iy22K&xv;S@3&>?f_7cPDIUVd?oc_ss|9(Jy*r5cM-CK4C-)R zvYhn^CzO1pR+Q++IO@5aGHI#LFk=?9-#Rt8Y?eN%#V?SS_fAZ;U;GI6(z=xAsPgnN ziSvgMxRZm6_EEs8Wr8{T(U2&zQLooWvw8_Lg#@OwZ)RHu_rwmIp_1kw)(~|`9HHFz zuC6%6(!)p<1RRSDu}6$4=-~%2Epckkd-j|SyvOYZ*4&)IJ3mR2|qQ-AV>v&`0pk-?FuxXdP?h&Q;zd@;<gl;0Iu+)hgj9#kzL{p@TO?mG<($4^M04Uk?uD57Tk`2V-p55kb_uq$ zA&zn_TI15qaj1&eqOZZR=p=V{^lzqeir7VMp*4PPL9>-KGOX;+pZn;kkaE=0ljBPc z#IUf@n(|dg-`|M}zYg6%q6W7+kWaA+S)zh@+$rpzX*$rrE}AyV6xOu2T|N%TtGiOI z9%T$HM<)o~&z7c3PXwkNsDf`MW<=zg`s&=!$Pr`KsSIp`K=9CWZ1w_!j z^LdeSn!F>X^l}wb^@;v^(tf-6fU0eOzed`(EQgeUsniK7_I)u&NsAwenyutd+kQmX zM+JYz(Nh+Q++8hEoepuztI-K8ee>hf)wF%n;*ivvh+Gq?x6V z4C%7~0fY!z3>U*af8=ft=7=85@|8u=F^5vPYZ4(F6KNo$C(}UY59MQT|JHeMaO5XyveqBzhRIc+{T9rgT8g|xvrW_(I~r#U0r+7IjP z#Y#n#t;|uOg^b5xk67x+Xu$jql>?ubuab-nP~3#bQ<5-O&z7B~>*E}1LXAuC$3A@B zGbqD{9X|Ye-@vJ!Bu@K;P;tfegI*vSQety*@k4;c?q2BHnRC9!+Zp2Ik!HMASb7bi zPM|cbbLc6vO?>)9D~9E*Q4zDrHU|z5HNKAaP8^zf9D{ijW5olKpY%@py* zf@I{?iRt^baAfp_+zj+`KM7mtv;zVKxxYH!_1rbz^{eYIz;R|=^K>lk-u(Dyq0p&w z49TTi~C1@~E%(z&MJf4TD z92dFN1dr0@Y3Xgc_Zx)!Pnm+F?L>9|^s}$n2<{S$bHI!#uda4m$4+&c=a&?n>fz3W zi)GO3yb{ab=AK+F9DTheyQSBJM_9i=^kL>!X})@)-=GR)4^njQ5RA)S!!GWA>9=@E zcctTolO)dlZ~G=sCq!D!=G-Y7PYY;dK!P~s(<1Nn#leEV#-s}@c}R))(A~E}TWC3> zr;e(&CN+ngLRlfhKgIAVPj5y;(0)jn(wwE0l@o)~Jf9*e(dCUENf zb+LqKo-@-ONBP$+zQVa|Qf0FJ@)pwWOmT)&lOD%Z9i{>%&vc&{CSQJ?Rfujb%is=b zQ0ZyxP{(`rxV;ZYs_~ScDcH8Kp3lMJK<-XAA{XYl;Q>D>|HHS4H6DtFDDyqpKpXRB zYBJ?Q^#IIa<)y3Y{70}77QFaZ62m-0BQ+)}P39de8mTX6Xg|m^@IAB>vvwulmyy<( zAgI-I2=ONKOGtm4Jkvs%!Oh9m1k8e=3E~Y2C415p`!`@4bfAaJ=qP6etxkyf>TO5mx#Vc{h^$ zxD<&eI_HPmc^0Dg(uMDSupn9c&Jb8&7d=S)RiW68+%2LAH{yiH;z!ZgsPigP9Tc=E zAQ0u7s^g7hI~dE3j@&gQkMGd>-X~a5pYTe7roy}5@rp0PVw8U@;KU~4jL+lvxKHRU zCPPu?2VM4%EXXq{Kww>N>`hXtW5_uKmWu)9@GUZBr@v)WO<3i8vX+n`;~*H0wZNY} zMUAzQAL8ybDqV?IBVtr@BOMhVh$`GWTz8jLzf}n!wSGofC>HJ4R z9t?j4G2;k53$U`F6~pgy9SM({-3 z+dpdcMVBv^b6d`;uv->m6LJf^Mh%6*7WOMt_CN;AA{QK^4RmxYsb0K^7XZ6-*cwI` zp{*RT{bw-wk%yt;adkTr4wb$$e)&9ka5}`5={nc;#=M#^kZvW6XoYZ#RD@fvGO+*t z^xIv~Jm9DAiMqM|{i$`qHtB|3YXmvefLil>YXFwt$CmRZBJq< z_Z~dlH6o{7EO+VEzA-VgA_ab*zEN3N&Ucx-*IP!em@-xXzeZZEc;AhlfP*PS=BljGxpoZMC?r z-7!E?%PIn$kLpfCT7`VKCXRbw_@WZgXK2w~pwO%!3H}CkM_tN5Ob2W`!|A!KBE7>h zjRTOMnSj!U{%Hv|YILr_o)&C3IAEYch4C}B$ZGkG^->XsO;{_Z-fJTaTxfHIfaNz! zY^1R5#1Xvj)<1g-j}K@Jo$i4^Z+hMVe(fGy%=9MV2JFuMLmzUK(O#k&iI0~cb*dYw z#%X-3$4e&&2Q2;DJH^LUhv+HZ>T}+UFl06Xfh35^cP$3V9;T5-5}ZFjdia#Z|J_3hq%$sBxfLBrOyl!Vt#ZFI0v3N4zO6 zoZu=^PRY;pMow52Vdd-SC0^&^6XmhiSQq~j5aP62Uolg z>cL7{K2j(>XzZ_EFkStT+o<#E6dm0={GAu)9(Wst( zk3R5Cz+kX)V(4#BpxP}-KJ#XL-A-%HsLXc8{9H!F13WT9z)evxIFZ3+gT__tH^|7x zow#44*4u*|&?i>K-gF@5LsqCwsZULx3`spdM)vK#?C~MH&1uG~&-QqcnBIS(NiZrd zb?=8Wa1kV1@ijbNsO)N&JQS;WA$C$R(sjEzMhYc*3m3WhBZf+7OKi33&LWC0ImPFk zmZQ270ZE$IsiY^ALWPSffjrdV(ek~CG#ld(H#sFWTaXRNd>`w-R$IBYcdI~Cc)r7^@3)#H~i zOHoTaqS%dZr&r(XRIu{43&Ug&i2^8yA;sZg$E1%v-@?`u4xz#+t6UPXWKKtQ?f8E5 zqC}kY=(PZ0;d9uziHUs74od4BM~CG=%DL@iQjv@E0r#6Lu4;rS4n9cY6Ml!1CQR`W z`8P$WM`w})Uo2VSI19!faF!y_d;w!J%&MkGDyAn@n$Xd3~=%M(}hb zW1Ctm!CtSnL*PzMFo7p2hzTR%Qhe}rYVxYS#k176O9l*xqVm0U;_UgJ=X zEPGI`J!dgZd3f2d;9{j)?FR4RiM6kVdanS^4|u5Lm~!PO#jla&j@7wXPoQB5)tljK zhhPrrNn{c|nNBi6II6z>24Hx;-^4TWF}U)PVAiPlZ;;xPFi^jx*VVIIwV&m|b5f2k zn%}M3AM;)16tfI&Jd#(@u#U2Mq$m1pqdhXyK0vklV?1s+Jf%0f=Od8l&s$40If0Ao zyzDd1V*c;@AvqtmXHUz2&=9`^h9F{cfJ`l921|{OiWRIWd%* z7e9zmhSz9b9Z%N1nC}j05qsgvQFOHr1XQA1pE?p7Ds)t?a&qk1ryTvpcrAyzHyh~ro~t5;ebYnP;kiE2*~eEEpDI4Z-Ze~b{o(5P2~XjFS2)kD-*u^P9sPc zz-e&R-JRojy2AD^b1(D&^^~p}Sk-G)6tf;afMRIum zgz3c8mIUkuS3*NnvtaBa(-Ngd%2Xd`E3TpCxF?u%5!bduWC^=(#m}^h+*ASzq@Q%X z*WujWj!y-IXzVkEb#Z0K@GkQ`$F+C}*!(ngm=YAzFgzQ6E|S31WtGzZTrde=#TM}X zf!R+oi%`OAnZhXjyrPsa{EjjEz^3jnR$-zfxdp^rS^YzlYG8{1UtYVJaKow>_z`R+ z8*@CVeJ%VGn8%(lfgy~0<_-jSOCeWQ*wlR^0o+Fv!}DAY*(;3lEvJMzAn1(kyNg!L ziptFt8wjwAJf`ZM)M=c*l&LzCO2z!1irWw2T(94;S1Erjwv-pzc+T_ zg614JjC{aE->AfFX@KL`RSK)Age$efi*{)S)} z11&@La}IPpn5C@fAv;(SQKNlJg-moW>Lk?4E!+Ks9WV@+rc4f-AyIAy(I-x4HBu&c zlBN@Ch4R!#h~e84e#7>(?l9X&wIV%{L5L1Hwly}G8AjO{&mqL=Dcou4#$lv25&*1D zOXUB;&?&*?pc-oYNHuEom@oCf&*s1&ps&ozXuTyOt;v8Iu&F~NLl1fwE%iX7`{NsA zkU!fI#DIk(tmtTMx-N zOK-@)jf>??CdXms{L?FZ13CqmRD@*X4~mZso((tm!xkN*p8@U&c^#8-4*7@G_(#h{ zZOWqJ`c(2LUH9AF1sfjAk zT9hHz)f|&TiTx8gywQvag{dw#*32=f12)#9B~ZA}?*=uTH_?#yrqVmKAN!D49AJ5QAx8|{G$h!KwR!7_jyNb-vCIZU4Ff^Rg1(d z8r~*I-wVgH(#$67YiPI;`jhS9M6&X0^i<}_c&JtFt9Hf2bmLwE>yX1sBANFcJLZC6 z{s~Qvh1pNCPg1_sHa*r`mjo+U)#iUW8)km|`CDcJtb{PKnd;4s;Rc)PDs|0G!Q66` zhPzr}pyG~IoB9sig}i6eP`6%djU|&XENMbyN>r6`GwmYX(fx7)pzuJW(iKty#*jQL7=4*J~4d8<+DNwN@h>3Qs>f($58^SJ@+q z5Et|J<6?!%A;Yetn4CN06NS@iwU+33_lOFIj@D`J(^QoWrUy~An8*$Bk{+q!DG*t@ zanrDlS?~M=eM-u4-NxFEuL`eePU^dQ-ZaAbScmit5&n?=!u0W7#q>WnHMJX+^ETDD zK}u!c!P3r=eey?x3d1m++lZNOYLxQmDX0Z?s-(Naq|J7`&uhu1 zAsz@GXx8>h;yYic>UyL9Lc)*j;aE@8(gp-+ql8A7F6?cQ;Nr*0_tr=ZBWS?v z<1$g`bz$;;HL)I%Xs5{5#_CkOuWV>VH8XYq1~(8eS7zzZQ^Zr5d5U4SQ>dzq$%qN} zwL<$?xORungZ8ETHs}DPK|#&R1O=BFbF3JZ#5%23(V>dUDJ{XRFtL@M6Qk8NzZVjft@?#`k*9pXPsWb*Kdw|jVRO{m|vX`D$J zr;?4YHCmoHZF?1MhxNfDQbwc9R7bo71QI9?9tDwbZpOE_uhc>p$eXa=1 z1_F;llQ7+ln7NuF&Kp{5h_)Ua`MQV5RAxO_U`G(nkUJ2)~})DdLXrmBQmkBp<|xaPlUavY1lY*q#~cm2&UnCh6@I1$y_Gd0n=m`|OV zTFF(O2QokYRL;M4?2Yw!1k`=av_#1Z*v=cfA5?7IOh8oUJE+e@?i#%eh%_-2Ov0bw zH>}D5L%zno8I*>24 zY-pU{c#yE=qeLc(Jd-4f>DwKlY$bpw%M#Xee=aZGQ=8b)?&6z>fLt|^41bx>^;lm@H+C1Huw^Dj2$$}8LlJLz% zuV+N%$`+q0TV<|*RC*m13&A3*vKiB3oXxrU(P9ZTiu})bHS#pnm;dkvf;^5ULnIzP zBNd2 z0_DqFh+4k|tn<c@~@E=$r0UZuFcSkwP9=N2xl<)G)rHP$+#7g*dXtHJ`>S ziro=WS8a|~RkO6I0n#_G)FK>gw^HoRi}+;%WuiWLUiPp|@W-t*qR6u3^@(IVuaFmS zVJK&m3+kT)lOqnD%nrP)$EI{72-FW_FbUtt%+l6v`@7l}way5r>^Nq=3yd(fo{e|O zr5}f8m5-?o>K@h%yCS6P*jGKLf(2OHxkG6Qsw8%fpdep8qC*^oMhXO3nNi^QIuV1e^^JA8Z#KC#_AgOOtNPV zoyw3Al8eQb4%V^^-8CariD|CjS=cNM`ZAV?aWAOKb^y+$n2p^>otj=m8XF_;!e_{P zMhV*2%5urR%P&5yMUA!!)HtAeVXM{Km;QwMF&eTkD-mWz2kn?dq=KitE*=@9-`Ik@ zj|@9{l%R^sT&kP_;{s!wvf{|KGL|aW#sC9?G=KK+6rn33I@E~Bt%Q1qX1rl?NLvqk zj5~F9%Vca-M5ue;7@o*1hlIq<&Gq6gYk016hBI;W%+_L{HIt*VN+uGT>Y`pHnqpys z1H8wDt<-Y^p0hx3qI6yS?pMhz@lPp{+i$e*?{}?kleIr~P^qq6!$A@XZ;@Auxfbc3 zFM9b^3I*^U^Ob7eoNo2!TL2-z9-G3Sn8_*u0~b~lrQ~Z0;sXoW(0%oat_@0mlL0Nz zb*Ms{LnK?StOv3o$uncBqb{~F zzMADVNjP~zRTY^^`%rZZWVe1|xv3jl?5&IU=2hl1vkzVFMqs#~ zw7a26t|BvQt4}I#1&DC5uf>~X#O;0z?h<*jw;+lkv!Un+({+Mf`$lml_)uWd^}fIC z%SSXt)GN9}x|i*-2G~*i>$DTqPRDt$Us3>ldOl1}1W55fPszNA zwXu0%aLUg=_2nkkJ_pMtD$=$j)H%1elz8&6bVIFVJu4-W0twh^H7i}&P+jiepy0%; zIsHctBp*}R_1xdlf}4c$==ZF1Q961o;zHWJH9ffEA5hdVR_Tq&Jz|EZ<`LE4JeIte z+f$VG3m;J2<%+Hq%jFUvkx@V7e>boTknNnEK9Lu-w28)q=*Lzo`4xi0=thEe%L4e+ zXeFoweieoXsm6L#NerHKj0+sd68kd332VA|q(q%=5`1@~KIyZQ0lhVGnc{h^F>3(% zur>-|N3jdWm_GUA`(a^;n1QSPlO$;@?+HaI^<^2Y=OT64ob5WTh-enY+=E#WI2jU# zB`INiLbn5>Irn_R=yLA}IE9K%rr$)`9cnv=gE6#Zxpo|99lQNk^XejHK^r|Dcr1G> zghzyLY~v3cay=yw8{c+231bx-6<~&{47J$Vw9EH>@IJjo6CKaYur=Ee?vJ~zG(06! zOdqqL3hVH3Aal2>jN_MQf3N{(fwrK4=bz~MwMRdzPSg*)&=60;#9xZKW@=jtDYpla~BD)+`3RP|=ZsPM{AyZFvW$sn$WOQg3d=bROMM|dS zI%nj(%0$SIYs{zY%n*miOlib^Fl#cinrQQ`8|@qnh*Tdjh()OBYH5LK*a&nyOE+rv zb$F?thE0_(*)sF@>?)8xoeIHW)B~@sPVXjjQ}3u?QW^bv55*w|A>Kbj-Pd}IfeZ=; zoEj`S7-OZiOZSAAEK$8xF*bJC0c|LxW9yV}Sux_TO2-u18s7RdO6&S5p0`OdA)0Dy zM~NxoYXle1Vx7~+C`rhaa(a~G@oZa5^lH%X7zJk_l1~vY~#;+uUVI(W> z7i%)+3)47{WGdk&(eL$u{EAleUu9N~>V~y!Gg3qd9%0WjkYxV} zUD5GZEt~U_9V2>yb&!?nRZLTcEQ7Ze^JBS=%7+03=S-l$9cCtZD^rcpiYL+v1TOL; zjk#6Jj?f+xr!rA}`%SS74kG1dQE+3x#v%sh`a|MLpKEmkW-jAMxm*tKo)PX=-QS=^ z%mu%7OSIQYeo|58Ts0d5*-9|E8DEfFDXa0-JenDV2cf+BT4tDX!bRI_a#|L`?>S_2 z)Zg3JSl3^~Srm4|LkHp77D7ptHWjcYg!-aB)r##4`S-|JKTDLny3gTyo;dn)89_mw zO?WUpyXnpTu}lD|d1jz|9WJ=Bv?+*G$$Wc-?#v&a6UNA>@n9a`?U~VBm=I-YdlcII za>YtS3M)?ux2b(-7#+6a&*JB*k!a3}ac^=aiO(AMPwUFcKq?R;ZQ;Vax>9#&7)j9K?pzVIZqu|Q2kaoZusG5&{)ZKIiE6Y9HbJ5+mb@dDQ#PqL zSYU5Ei!Umf!T6;}uggp8>T7P*&vQa5gWgg2xs+~T*)a>VoYZNN%s}@iVf4OK*$-aN zudYKWf(A64HNPX5DBMOGJJi|e3(PtVn5D4d>Z0bga+I3&^Jdi%ewf$+Y((Uv526y5 zN9=tcqz7O*iV)E>VdHBhyYR*$7B%Ayu$s(DQJ)TMxn!}#S(hMr)a9i%r^2Td6y#0` ziNpG1xxBBKbpjq585kaR8OR3B{U8a{{|#!lAVG`+cP|Nlz(J!I_xA>>%`bK;=^dvW zY_7MjeDYk)>cXqZtmDBim$OG~z@+R@>;{W@8NUeraiO@nq?erxR7#Oh-4M_2I;%hh zV52SI2kkXn$dBdoqBK)c7!4Sys|H6}0Sj%_cuQJ-F*G(dRwF$Tm&nvyo6a8_W*bdWnH zG8xApON@|Zu0Wwnp)F?eJ0%T$t~Ys3iZR!q?P8Qk`p_9i?7^7@)HGIYLjy+8$J(df@XoFOX68+(})9grk>ddHLw2n-(qyOWdQ@BN<~ zl4B^JjitY*cQ&B!SEM7Nw}oYH`Ya+1usc$=!?8+hbR=hj9If}nQbs(Y1`Bgdl2%%V3k zf#|(Y$~#X{UqyRP2pt^dCMK;r^H^qTWbOhB8B^|_sW;o1^n!fK=n@Cds*M7BAx)Lp zetOCal3|&z480&?>N**^Y|?Y3{ANr666tba`2|(6dYW}ZE{e`jLO5gLD?2Zfrx!&V zN{JspAenqy*~MdQXZgN*`&|EuT$<6n3}E+2M!|!< zNkyK2Va*e-`XJNlH5#8=d03;mIDy@G)&s)E&7OTajB%g(^g@MMOrd0jTzaUylr~09 zVC;jZUM;&G{s3KEOdwK>Lx#`M#0!z$Bu=Iueeg+^`xpz36o5~Hz+OUvBEv;86q=NX z37q6YN=dH>YD1;(d5Jthq`T9?9Y=aqMo2zkT_BfBCIs$a$zSBf2Ei^#Q^6>+=8538 zCiMP8I+2Lf$Y8(&JvQc+dgCg)ghb)Wp;-;`+i1J*Q}~KRH!_*YXSo7NsO~INZd~Dy zQOxz-yp~LAE_H<_5Y2&jTn)I0Pgsu-68?Of{uJ^PezZIT6V@(nrjR4A^74KB@kNUxC0TZm>BmCt0U&r=b+ zFIm#XNr}=1=ut^$c;`&01I8CbW7R>{l);KnxkDBM$)+%8+x5V)+i?M{USo%=8<4>C zCNjuqiwTbhEasYZY=gNox%?Xx?i%rQ5~MB1x(3yX*`E z`JO@qv&v54lOvaT3Kih>2J889+SJafW`6UnubK)s!1Q9tl5-zYF*xhXj{4h=^{T}B z&A5$gND2eCt%qqN8I6%2csOm!F-2i3$YRnxcnK6P6mV1A;$_EK9?6D5-T5%ZQEk`c znm4j@%7>$06@l3%rUQ>lm*__?wR=z0pA!t^1)Z8(Po}l*%NwK)gp(+?91afS+r+Sz zDa!ZEmCG_$jXuD$l-(;Rv9A`GtXLfZoT(nHc`@IVRVH+1c*&*N^3JF@y)Sf)Txtg` zhrN`CkiC)Ox571Feimk>ww|mJ&cxZmGyq!@9m{chCa2{&`L@!)*8FmQsnbd3TdhefuZNeK&fu+8>aBmOSG8|X_7kM_J4GJVXwWr5I87)czt;i-G^Mho{x62&hSr%Jj6A=A9 z#lu?qg|gTiuH4iQcp}ui%Y?aabxAU78ZvNSyA$yGa3KuF`wi+(@Q-)ucu^?~rg?wXH|<8_+k2T4 zbZ;W0?|Fz1_q=JjRi$r8Z{(U`-rLpy@{BCsUqL&Hs#};9zDIjr#Eo573(`~VQn0ymB7>aMv-@&EnHwTYms|J%0$ zL4^_DXZBFluNLJU32oEwf#`5Ss)!(UchTxBYNQ_4W*6*#=m;(wM+C$Rkw(PC%Z37e z;LU-}+@^2e0$1GMf&zrH?xNqltUTmDmFg~^uLlfH!#2q{7Vgq>mjQ4Cgc9#yc&>Q< zimIoY3^K{)UV2&gHgUYKOyw8H9bD6nAV7x1@9_NfB18sw=Pu^mhowb}S=YO0e26k2 z5-tG$euJK^cPn;BSSVSXiDh%M{2BA06j+eXRR`ki{d)q;;P*`a2K`J;(^_!yn^s54 z$D1zTM`@B$I%5`lMz~SnwgYT_K9}!pgfDNeuZ6biy(4G3c9CHUY;{Q_-vz0?G}S(K3nMO+p8Nh@#Ey7wL{Uxi^Rdw zE7o1lSF%5PU4WH>8S#6wG}O67HMF1tUJhFs;tAh;^O>N~9t>dStplsDV;2Y|wim}P zZfF0|{WU%gR{sQNH2Y7>A1CC}-zS~wgMHZ;fUrW*O^(6n^pIqk*9vvB*GscnjcBG{uo+-%n`(6RtA3G2( zbhRr#k?UT+igj|L3W@z zLYfIeGqQp@dBx|?OSvGz&ViO=g{Jb(dh*)6%lRdRVq0YLDDiO~+C)k?RjXpcd0^M= z3rtSs;?*9msy!xb%ktZ;W;L=36ltmGbG>h2>paUORJZsPEM(j+jBUGW?6j;s9w1P^ z{Tjg!>$R|M$862k8xTgth$W|3d)}+ILQckptG--A-Q&1nY-*_`mfs*#!SnZ_PVHin zo8geKWKX6c1vx>9h84I|!}`^W{m>2gy!*Pil0AtH<9BpOyNk7p<$=P39*bAsoZ@eL zi3dC?r@JJ@a4_u1W{y2v%rDAfdOSbhS^*9PsW{mrpNu(+B{9kDsVbCjG|ec8p<}0h z?tP=ie@h#^blI=({;9S+CYqhH5Z}8mnT76CdPqIQ@AT?X{_{(ya}uMI&95&uYiJ$u z&0<#V-(r+nar6e4#ViF3Kkqm{vY_wx#j6d^=sRUWjz9gu`yQ$C0n$3<_jManPFjH; zZL!q*N_1ziZ}?^}oTbjQ>;y7GI(j$+)}RCdwhHX~@9lL_2pZlY6V|Dr%Gg}%@i zc6JJozwc1Z^-vT7S%Cnul4w1M_&r(b(9s>^W*-?u@_o$4JM19hSJR*2cjX{R&5if% z+ub#V;tCW@geS#1oGqdJSvFgra zj)iw`A5SYKKKLLl)_@%PNP&8jlv8KmRJ?J2;Gjml;bGYlO)4!JYpS3PBYuKKhd7+} zss|5+WdwZ2N#JdX9A4N$E6*GCfluvA!>Q@kNSpX)Bw*i5fe#zwZsol%FxtO|FpD$pmV4z}6LbAG@$X_xyNk;nH`yC$1d59;7WJdSI%G;-g zF}Z^77bt?@9lkIsi;$h#h*Z?40w z5Ygf9HX1K?Mi}osjl_w{prY$fIU(r@g&-}urf-Hhhx}sX{(p@S3)8le1|r0UrJh+f zp1aTV{u?0{2qh~;csYJhZ=q>ayHo+N!LPSRcdRDf+~l(Dj`>{v;Y0@rmIClTfj>MA zIL!k7a=)y1^IjmB&aUSd3*-Ht!8ZUK0$7~wSC=dx^}>3$9O5<3Y;Fm}i%q6)qwWCs zyP9^n03d#PUYNeE`v)fT-HG#EPJcMx=pD;4r{KEF4sq{>;!m+o?h5n{;I06CZh>vj z{k@JLoYA%gZszenZ<%(vL9$cHhe*=YG>ai2zt!f7Ac1`4?^o zQ1T9hz2Bx#)`i7_8zejJZ}OjLURhEFE}9a7pVE; zyT^BEO8d7^e{dW%-gmJnlO~Hvsim0zSMdDA{3v;2UzLdHJc^UMiweJoQKVp7^&?cEy6oQS$G%eNp zj~Q7?X|gFc-mJKN;jn~Iyh3~Fid94B@ygN7<7c|e7v+!#{1N$Uzlepgq{>$P7b2N^ zv!k|ym=4>bcHQ4KEXKnCS3}l;Wq^=|%)8ITFDv;;8fx!BGcUz%6k8# zv32Uyx6Pqi`=R%5Py{w3f9m6)>mC1a#f>)cd+oxeU|Y$ocBqMno4t?ofwK#s*AhMC z>G_eiQRkDn_*y(B{v;{O=LLZ&mc0=eu%9CJC39T^{59sGIlfK#-T4eVtniY;>}-d1 zyA6lV0$TdsNU)T$hZm=2rHfr2%SN;ZjP(Z+u6BMy*)1y*odkXbM`{E*VFYeWT|ljk z#bY!7&$sp`As`u2U_OGnr}H-mw9j|pfw2W-hiMr^(>vJnCMB@4T4$kULiZaQW&O-A zP&&^bJyc zLsn_nmu*P=wU$iAQ=!--U0ImTs@s7!0M7c4mQk1_Gj6Ybf9zPa&Tl@F0RVCXi3bm6 z`X1am{syg=yiln&Yv~n3)c|p>p>3jW3DCc0RcHT^oQRFLO{;(eldm4rUp zc>g>K~c_pkYF22F?nO_nLx8u7LpyMvf5=OJg zEp(WIhj<%&-o38Zopa+6+LrHxcB(S&U}HK55xhz@yGb^b(+~YZJla&oP>NI4RVK&D zQb+C52y$vFQ!Eu+Vw{KMJrlgd%gJh%Z&<^SEyk5zBy}36KEb!3`HoF^SEHV>0DF%3 z2#Ls;tr6=0^;-3BO*bl7f2N`_5AVJac=cMZH{4jmApu*Rm(1%Q9a7M}s zIPaqxxAd6nIkM_2Ts9>FUn8zqG4Sp;hmR05 z*79uX_~#Ba);^{hyizec_s4zJ$)UnLYPMVyO2f+fJsK9tfE%s5&@SAVV2!#G@;rua zIQ{U=xo05;a|)u8l$r)xfKS<^_pe7n`k%j*4uQ)8l~1{x@fl?FH^_r_bh_Z%V3i2coC^^zYJ|Qh<1n|?0bn(Wf_SX+-2-}#&+~ro_5JsW z*M-j9vG>|*|JGW+wf5eWM(_I zBv$H9&&;c^OlcS!chmNuw`N9SD%aRwTSW}HGY~JEgipc685x|s_4g@4vGvE>UeUuS zun#n}R#@QMO*jDyK22O@+&6bMKu~In606 zB@=b35hx1k0EhG>OknGVv#415J>cmHO|Ok6;NWT2DoXVuELX*NAB?Nrb$V3{fJX+= z%gB{eD!k3_@(iUd@Nt5r%$>gpShJiFS>XK+n{H^!RV2IrN`b(BW0T z*2XJK20@?jcCU^I*&M;gqj9w*3G~}xHuC-0=O*d6ZS`%r_ z`@uF*+g=NvW+s_weW9}2@iJOUAMrc1(G6^;#puofWBSofQG=ae)X$*kSy?;U9Nu6~ z@5!36*T`kbYI(*HBo!(eGKI#3KSVn^^S^wLm)?=P_Sk+n(}mA1VnuMNGoAPB3&Dn~ z+jXdEZ-x6E(e&JK@jmww06bFiOzzw|IXef?;<3 zlQ-oS6e?aZE|(5{D66(r1}b<>ubeEFa3h8;M!3nsk(BMlZBd9cvz)s z$1{`2|2bj}?)mYF@!6edf0zU2?zmoO#cwg0$q+= zS{dz+FQLH;j`w-x^NJbz3~&JfU5rjB-*Z}4kb2Z>%rAdF?N0V46)n9f5Ee#SC%fA~ zo47DF)supQW{;dBK_4MiTMiV9H86I&O~o-+&J}G1C#>)vk-RdfFmNh^dltXZ5fstn zxI_={nbABk`Ua6>Oj0O>nC|$0(>42mPVWD1-Qwz10p|;{{l-KVJBUppA8U`}+qN!~Xc$bxQKCDc3*3TY{t&nrYqp>6_F)6I$ zKPS=GpdGgNVomxZ7v=|8%A!vtmsK>ogQQc}mUA6wr{{BQN42UCCq%_y`>=Gdj7G5W%uK3kLEU$6HZJw>NYHKj|r^13=t&bO6a{qNn9Dl-Tpgc>vUHxh=e7|n~04~RDs^!HpQ zO2ZL$3lCxY0L$|9xRIbIoB?0!k_NDHiBdZNfeHWPNqmw5Q*Qs)XI)J~A%8sR5TP&l zCI}-8KcqpJTe0EaQfS!V8~*d*(3{5eT*X`7nAZQgx&=5N718*3`M;4+kpAnBC?KQX zx4S@ZFN}8Rsr$lcq6~EBrWIg(y#wEyPSU^o35I$mk9@1W84rRhmUs znHDMTnD9s`fA}WM#mb``Ax|*A!4_bJCp8(C9Tq)bQB6;!JbkW-L#& znpwnqQBWtjOoaN(jtORb2%n zqXn1T6!H0Bh8jlGI5nz)ASJSCuGFo9#%Cyi2sCb$6F8&bMuq}TJqM?6(H_RM2ormX z93aTT_1M~7?t|yW4WZnj_Nr?GGOiL6-+jbFuhz6u$SRD;?$G*?ObY~f|FesvMAPZU4`F4Yw5v1#C)*}jOSn#=2m$y#1(=T#!fh;lN zf}!UK#W+5>BD?;{)Sf(wz>a)`VupoHi2~=Q0>X+0>{Ym=N=R5X4C0S@=R7J9rU}ew zV9sfr%~;0Kh4h;|gQq|A5R<#X4@ub>OB!IBGZbU|jkFa@tn-d3`5uc%3uy`B>!@0f z24Hs%Oo}X$lm9u%Pyob;pQleu0iF($T@(Y_|1U6bAYRHVX_l06biZ;;Lu@U=J{h1XR?SQDYMX){`U`2j8e=3mRynM z%Kx_?LW;T)z?;@WNW;wk6*-1z140}-{s810VTC+?^fU<&*iE;tpo-)eVhD7FBAX^J zuy~<Mx>G|d2pD1fxF1PwrA}B zK2M)3{g6g_x^ya35O20WA9DVTS^NQwiIi6!ImgsP8Cc^Qu90~`zLWgRxHNJLKe`vg z`3hL`$$T?5yxvW+McVMiJMzDtMyal|wF8_~@R*!^nCdM*h%EdQOX5^7iSLob6~^R2 z$x~`7tXh4QFz>0g#H$N_($D2GrJo>Ei!u~0^)H(uc!=_6Qn3U0h&{A9K5VPo;_R_% zhO2UX*pbVZyNeCV3vmY>;+U;}*rpy05Y=)~eqJ2UApYdpSGBiYFPk_rQ8a3UMP`;S z^*obD5o#NAOcU?l1vKVSdq@I)c>k(3onKaTw#%+QLmDor^3 zsTu@Byn6l!HNZQZwq?pLyhD1yj;w>5P;`czD9yI7_l*U1a0aH&BTI7O1uELT*08w^ z>PL%a>0YEoS2OHVRCanFGYM9Ph3JtBjm?+hgcrJ`1LWJZja&RMJT>m2tmxZOE^ygjSbnADUKX|$#BNf5U=exjJTGQ^fQtCvVDjgaB6Vcqvptz z6qi13k{;cH1Yo}rT+Z(NUz3j_E5C4LL(&qKr7)^4@#*f__@_7g6N?XmbYG=3>eKpi zik}_kJudnOXDs}q$bt37Mebr@PxHp#gzyvt(f$ntSxd^s0h9z_FT~8|8ZN7Xkbfd| z!+?=f1&}#Xdqxb{O~&8`Z-eKkFO>;hOM-kq2QdK7(G5z^wFBP+K9K11c2G%oWw^O( zIR~ie7{`F@XUebPXLxUN3U&wFpMC@t^83C1?|^eOZjkTRL%c;T&tw<`@vt}NHh)a) z|8X;5fti5$4&eGv@b9+(F0{b`;-e{T?#m>EpNDPjkDc*V3Gq+#(t9Dln+#Y;np%7S za<4-5XANpAE@w;v#K+$;r2!}c_7|}?f1{9wIo-Dio_U8-hSS`#F)gW4^Zxv{{T_Hz!r1YepgCDY)HuqS%;i(eG-=_ zZ%dcDN5>PCphnq7A~RYxI#5HdmTsN3BzH{=&qq6Fi@3|NE&GOWP1>8dH=pGECF1{p z!fD`5y3gGVKUm2>6Cj3e2hBgkP}+=zQoqm*2EHEg0~W}QIiY4z(M%>vO$J9|u$a~I zM}g*W>nSiwE$Zh;mFq(qigYw7X=ey5Glq~SS(kFuJPuMOgA=kGt3fAWD<5Ag+((dygwXhugZ^-@Y@ZINSwO=fW|9*yFKv~#r5P3y~jX5oy^8?RIQJt%YS zYroWNR)inDo*nK{%az>&%LdXgJkT$q-Mr;Z*~-hpd3c8GQn1{pw1VbY%;>IoZtB5; ze$Wmv(gK)S2&6e)e}+w%5Ni)1XjC*)XACZE>(gPxe{ehrsP-slr!qd9{9k(z~F_MYnCv+iMNbMKM_h7 zQ@5o7r~AujOC1nV;^)83qLVTELT`Ub*4`3SMQ(4hPKnV<#9CvdB7fuD_50ZU9wq?0`bL zrmeu{DneYai>}bQCOXSHt!NP_BkyLt^C}t59FrWaIX3Q&Np7!fhejcXvWPVN8@|LPqNX@K`UeVvkZ%VScsI23a=~G1+mEjFtt7C4)YJM8fn!8O8K4&W zRbwf<@|>y%$}iK`_+w2y<=Jx@)Af0V=L%GXJrvp{t*|(_3v#`2&9HT_TeW>Tz-aUY zIB{A|*8Cp=EIZ5oos&yIya9M+rrGWJyUCtwUBU~d`b-Jit>h7VugF}!T}7E*&@4!; zvY!bRD59tkQICBtsca!2Ck-Rve<4R`HLViugz?!<3eK9u*yF0vm49LL7YbCxpDUx@ZS(pV z#MI734rNX0lPTiN;Aif}m0aO|HPPHSQqwbQDP%Vhlm&O{S}*CC4|^mSoJq~J0@23q zWQ=UGgXp$a#y?d8OHiQ6WoMC{g~ z7mc37R^$27@U9KHNYGcf9$`9jakWrsu0ol^1Kb(xBOQ#6{z$in==)Ok_(bodDZ8dI zm3GyNQyDgjwWgv8TgP$;b{yoS$)Q4Ssll2njLP7~611Y09vqBMzeRoDa|O|DkcIyu zz{?`AtIWkqUx0`8Vz`}$6z*ZFGE&kdBKZ{_wr6hdN-kT1Dx=V*IhStzzA`+cCmu%& zRl2xY1B9v>f-)fz{-hQ*(B2du9>6fawFJr`x`L&=LtQf*f9Z?WOl93iQJ%%4$^{*2 zmFRFnkPniDfSu_7iVB3@2!dc%M8M>FaDUG)!hHGB>V(&$}6s)Gt#M6;;+&ly^t@3VCSH8(fI&(jYKJTdC5*cuJR zP}b!`gw`FJ>ND;w?DsM5w3BI7>Kta5lRmsJP|fY=F~K-LMyR1&q-{zcot44hB^n;m z=ev*kM65^eeU6=qj8JBJKn}f61c5Y9zdWBtsuf|EuQRIB6gOHoYtm#TLH919<> zq(B}V9`CP+7;f9H+o%JrTR|hcKgOpMD6oe^4S3eGy!(@}D|D0MIcoJwpND)9;QjQhnIR;t2S|)yl1VZl#-CpdYRVNiwTaPa zDiUlYRnYG-C=p&>LVsVaW+{4-2ha3_WfPG>Jej&Qx-vE1l-l9I_pKZ|D(MnpW|`d9 z6%ACoM{796R^0PSR;se7o(~Fn703xPWwq$-oN4szIZ#ID##q#_S;~wG^UU7&&U9Io zCF65|{(2IekeET51u5rf8by;X-8EjYH*oreMfZJHfMg!?jl>2Lm3BOU30jp7%!{!g za$G~S)`Zk=_t#vMv~qBw>7LV_YkcvbaxDmsqj%Lxh+c`js2!H1FYPTvC*$lFs8)I4 z$R!7xi>Wkuy}{IH)2;OKQ3!P?Mx>LLK@V<&tYLZRh*qx8&~5=Plbx+muZQ`Db`n&3 z{T|;BE8R`}kQ;T~`|;Keh))5B;`G*?D08v$rzZ4h=V6MlNFMKIeEKR2WY!j&Z0bN; z#A9#${azB12@VS54obIxX@w<(D_Rvg%w>S2@|n$2a>ZVH#y(-pMJU5;2UJ!|wX(|q zhGx}whDdW3qN15&5z$h#>|V_7s9nUxPm@tgGc)*0ac0614f5eq{jE|j+D{PkxF2ZI zji+{$n|;PI)>U;##7TLNk*}xeTd=dj?zRm^q9DpUy=B_=AUEzt0}64R7>~9s*N`Sn zYI#DXU}^R?2dA|1<91UR?sJj%bfKFimp$7e=@lNjf>ytd*#Tj_=FS1(UJegL-RD zl-RUDnc%oig4VJK8unO~#Us#lh5I7kf9@yeZS$7x%on ze4DGj)pE(TxvgIxlYz@s8RrJk4nrUDqEF5#o0RBdF{hEhcq<1Ioxq=CL>R-m^w6eP zw2=&s?^=3q6SYu+bsn~L``sy7gp?C6ReyT-HqY!HHx-|lJHzW}95BY<9|%X0jc)`{ zk%!L_mMnd^ZH?C?v0zCLQYF$VC0B>plq$DBwD%-oY0>VKfgmGS1~`XTNX?diD?vE4 z<|SkABHEslR}JfH19NnzEecdl)@z;)D*EvC;Iy@nZYMM>M_-P9`$0LWcGFMEmX)sE zzYMne+Gm7WL1j2L0l)=t-u-P3 zkSlQa$3Ibq4*xJft$xS?oL`))D3^06vpjCF2gdR34#)Tf-Ic;h&eGC zxlGRTm8DWaqLFOKWbc^Z(ty(=xXA$BNFQvZg+>N`=~rnW0*ecN}^wrY7Z_poEJkv4W#(4l_3!zIUFfs8I1u}+|tIY`j*j;Vx0HF zjSOrE66AYT`&k-lP3preA2geV;rf!Wd>iQNK+d?kB|;Y0Cdy7vWa1R?r%r|Q0+DDO zjcH8(KA_&2OgZP9nB|o`V&sft1NjW(bz%I<#|fTTAiP;A0fW(NT0&lX7P)R~rhVN0 zf;mi)lpGmJR3%cS%PAFX1AaILDI|Qg8MmKUZi7$InZhdsXM#Fi&V@t`jf;#-(IpO> zZS)sJzH3I%H9b91rT(rOx8RfV(KU3t8;@yPgRyO=VaRFK{!l|ZcNv#^IC>o#FWww3 zthO(q=3Zeu7Sr_-cQ|bj6Wjx4z)$!6sAY zDmzSO1**ucFMnJhWBFb)qgsWO8YK+(?s0I|h=g=9B$K?{E4syEGO4Lv&f83_dJTQ@ zyWEGeSWOEY{~Uc^Y#F1+wZe0z;}Q)$;Z3-zat85LeX2$DN$=Zkd~7v0nedB3H(=UY z-wPcoyP2|zfplTXBi%TmU83Wl?QVRTk0#>hq{!t>5#l=0?5rsmA6&E@mEIB!?x8yq z8_V{J^&3i4IH{lpDHkX|vS!W7FTT`(?mSb8P$zUKBuDk?=wGFn1`zLlW21#35SnRH?5VY@M#JymOzgZC7+PVEuOJg2XRc^(7q zV7u&fy!3iJs^GA+6@eNkS~@e^N-cbciWgRnh0f5Z-Tas8&7dPV1i+@^fh}imVk^FM z4^dNvj|;|wuV5(lq+2muF0Np_eqDyNZz0%)pV4ZT_EB^wKe0Gt_1%#RsaH1Ul`o9Z z3@`JZ!ej7IW71qK@eOPa5$ef$T{>AG$92zqp%~iB&Xs5*8I@J_T|Shx!E)A_Vw!v0 zP@%NPk67+G6EF%f+-;g|ApG<8p~2 z`tf$D2|0K{io98IE$0LIAi6Q!%FS1;rz80w_psUKQjL>OplfbUKgEYQa>5h0YJ=Vx z*J#2>)8cQWKw2ojRna#BvNW=5e>%#dga&rOWJTmp2LFVlVEh76?_mT11(>&h-j8Cm z{E$t9t#%%fU`k)bK*7W0Id_lh0P{~BeT1)**nNuY_#kl4xiC92f*B2C;ROwx@HxeP ztZovuXW6eA9#n-gD^?H%FXcLb8{Sit%WZ7sB$*aWGu~Cxc$7>t#Fl24fx z$-ylmQ%(I|ga!BTYH7rGnIPBk=W_!wWeCRe>jbTby^ci;bcBv8Bnl_Mws5TR?U*>; zC7>ntVI|wf9+dELv=$+57AN?<$F#pG2)=uf|F!|xyulbg{?>O_bM%;;aYu>VnH~v~ zl4#!s#qd}wtW^i8fhNX6mR9zVLda-T6j-bmAjc92H~KC&COyN1r`Kc@Frlt9&1~OA zCbznR|E3SuT|`_3)T?6e*7V4dWSTscyh|{*2gya7si2{Cu-W=8-b!!tL)kKIA@BXV z-q66GHvj@KVjv5%AoPJ-n=x)M#09QO`B-a9#IUFOh878FnJ4HIHU^h^dlKqb3}Izg zU%F3PJkdLwe9rufUCyqM+yO2FD%NJ@5TU+)nbukm?gg>&gg0MD(DrL5FfP1upEV6X z>me$oPg2kDKyvJv3|53I!Gz=n-<@H2SdJG?r{;$a3 zqw3l6)7_^mON|$@sCr0jzLTR^i6Zd_;^;&$M@&t^>gC>j_(>mA(@BU1sU1sI0I#29 z7MC!e&X4E9#}0=5e|`TKYa%^o+|vhsi|0|+S;EA5+oh^L3ZFS=B~*#j7QP8d8!3M^#9}NBn66V zpIT9*5r~QkXre;{h8weHS`>Z$(P&=DfNne}B9}NL?R@>aO&ZlaBo6o+2^p+}u5GRfQiLAnSPKqaJu{ z=xs}2bjYu|czi_}o5hPs>f+L^I>aRkWrWcYP6K%h6L)#s-s-8bOuB{>G7}?aX>7db zDm5&B(PbB~G(lesAe9J5{GT!61!lOlRHwV_Mqn#EW@$9WQNBMR}~pczM{rFbac!Sx3*wC7R|>= zFMm3yFK5_?F{vCW-B%v|*+BfqM`WGZHk;vXi{k81t_T)rI=gZAqhD|Xl(xFN-Q;-@ zzHn3^>ojo`_JBDLowc<|zgwHiU_rS;b^+mbNb$W39B#Ll%}X(lD<@u4EbcHKLy?^+ zO|yh^OWd$D=ofG;tB^@JJ=J8?(1OjV}!Kkhycx8%67KQPv2%-pp5-=Fw zXa}f2xE&O6kWZ{v68_hhWJc?5fi_Mf%PH_n))jOI`8`p2@%B%@^Cwr`#Uk!Bc&e z2u8Im{H+i`lG(8vpzrtkA3Sz|w++xUK(;-O?k3iF zF^-E6`^Z<*sT)MKmXt^y_^-R_5O?ust3TYc4Esf1{xrK$DfN|`D3F+#;b`qPI|5L* z{G_n-kWI1J$>}1)>3gBLyK>VrEMD<@c)ToLuv28u2ePl!ZwZ>r29SjerE1hmkK1$$ zzYfJ z48s+yQ3H2^wGj8h${gLK4VPd~NQ@!LkR8$FGd-ad#AqKoqnuA-GGFqn;vC z?IlZQX3gdy)tKJY0$y$syG8c+D!+Qv%#Z{W?@Io%1OnnI)0`4!2Q2TUcTqI)mQYn; zgBX+4DQlAiPG!c(s6-k`rp?qz#v!s=^-lfLT{_t9jAWn5?MI(4o%lMiwhGS zR-oWbrzx=nDd)&y9-=;kikp|%QrO2ni6s~GYq9`p5Aa}<{yt1`GdB~k z#Nrtt>YvXCT4XCJkqDGQ+;FRZPKy8aE1(1|B;@(!G8`N4*l zC}Ny?q{pui2*7p{U?D?6LDw=e3Aq)ujX4dc_(PVyz=uyubeKdK!oZ^8mRREl_g>v< z-(o9J+znJ;Dc%Nm9sAgz3>3Z8LyNyfbO@B~4PQSCv&>=vECovw>nnmv?u8q2SCLeATIy$%tvbTec}zVy4fs%cnuyN4fq{TTy|4mH(Zvq>iWk=Pg#ZU#b8p(Esg0T9TfKrk^<>U{mba;AT$U77bnl1c?PO%Gz->|7MBR zHvrhfT17<_d;i?T^q=Ih`sU5Y#EW7yH&n(~Dr?00>0isJ|Ce8{Y5(8Xlqz3B5DASR zKtjX2|7ubZPZi-Etq0R$|?VGlN zqiTVpLjk5^4(Dz2OAkM+J$hIe;&^*uzovzmYtiRoq{D@m~FkSN)3ZYwC}YpL@SQ zJh9Z>+S(AjU<;hP^^A0iO<)rDzj@oh}!dCLH5Zj4pwt{gS zVs^jpe>Xov9OZyWK!@A~F@GRhAl60W#Ye%ei)kVzc<}Dm_9Gu(rr)jjy%!0-EqWsw zmq789$hehSx$cJWy5pVz~OAS-5kM8b+X*3$Gon4geEAQk0}#1r5SI> zuPJ})`Sylvx@=pN9{{0ik?oHj-e~q2Uk$dGrzi0B7HTItR9?=QnQzjAL+IzrwZ~!E)Xl zVM@4Mse~218MPUDCww(mL?^?~%vfavcGRPXCQiUYttRW(A$NjDf6+?sjE7+y+$nsF zU2U#&>W`F}Nr>u7l1W1*3dO^54`!F(wRqk~leVpHuR*8N8R<*ksVrYI&!mUR$l{EA zg5xwD(hH_L52KT&y!Bx3I|3;^K3)N!YH#aTWUL(AIjVQC6Y|*4gxRa{1yKj>$Emz2 z29^q0c+fcQxy|Q0_UoYY9*I6n{H|3eT_nw}IbX#ZhpSMIhR}kjt`?n-d__Ojja+%1 z=-PcL7)v*jGs$qEqI?6Pou}WV&NO?%t5IA+z@Xxm*KSCA7bFG_u_M=E?nk4fVz)Nq z142-&7@k1M$1ewE#3Pmmj8yr3phZdM^FZ@~(L$1l6)}^BjkzXK&fcHse($+>@S#~G)pgku;BEif;7LO>@C%$fAAy2sHl6CQ~L%mLHjD*uwU+oC994a)5xpO$@$)-uL7UKHWGd#Dv6z zV%zPioK(YDX655?l)bf?bU20l+~nb#AB@c(`!ssId%~>f9ZXTx%qBdk6as~B0_4=| zpu!tC`vp-)xml+HYH!~dIzC}1%Rfg-c54Y9b!=h`F-TmBcQ^^o4#+Zvv-Na*DbY$o^~Zh(W(G!KZ zr-vBejr#?`!@_)|nWt^F6~MGxSzxNLW=h2tZs|p(({J#|#$$ub!k2zZq)qs|VH$p6 zBnEdVf2Ex^f!URJ&M+mZ9hQ9SjlkYD`vm&%?Y7Jwuf;TzJ@D8@1-WBzRISAI0Q)hJnLuc;>&%gab2)^y zeI73Su4K}yE(oT?tl z$*P*Qs3R8i$lQ39;N`vh3m5tj~ zWcves>Vk?=C3R@&GevC97`V&fKZ<7bu#v-p;o*4b)fCaE&$MM|tMB@R<5P_aHaD5u zkI#9VI#DDs3n{5Z3%Ka~(NK#ZEco!PgE+LM=(9PUXpc_*H&T~G9wC8<`5hmVA*Fyl zS@l4|Q7G$)8M@(F5;sx)_=+UT3uLl3`t>>0E@72SGLrRqX`Uc5j>70N+?HymRf_La zPb*inccU$<(Wk==Q3~dO)s130$2k7qqYsPwr$19TnT=6NNUL*HkTEk=niU+XMH<}t zoSiqRyq>SbVYlJOt@LCDM~X^R2xd)oq)$?eBTjQg<&VBcY8)M8p5pif8Y^#!H>lp>A+#Rj(GS~z6w5Qn3;Bf-PB{R_ax^^dmVLVUH9oZ`E%=NJ z=>W4!EX&87kiP(){~9!++fz}SlCm(IKVuZr%_3%=PA2F=ypfcZYKem63i(rTW`N{w z!=Uk}Xn-2>q16$r7Mn@Gq!Zw?M8g5e3s(v`?Yk_+QoZzj^udF#rsG&WO#x!jZ|1^l zPBNPXz63Sq)Yc0+J%@iT#TsWlVjNX0^H#odqJ0)dZ)U={%^3RVeNgdPCDxk6B7w#h zazk1Pw=Eys-I=!C@}KlIqEz^fjQee1XnD@mdG;Qq@+Zh{=sp@D#HnBRgyW#p=5Hjb z)@{*FXiV!X^nrK{ZoAwUDJC8^LxkevR3aKK~wPpBseiP&H&iC2z+l+bFuQu*HAMr3X%Z#i+#PVsw z$Kk<(&Yt3sI4s}w5{$^OC(qlQ6qD8M!#LVLmp8uSUdtrFydpQ$%7mgI_wz6DhSunu zwgxq#EZOGWJ?sDUh9X#R9G#kf=vSNO5tH%1+f*fdDVspI_t=bA*-1_!ZKPNufnd6v z3v<*Bg>*hfqzvzn3a{*Pvm)FA>b##Jy z0fNVOnB&MKqIM)bovnJ^v*Ma80g^^gZp1iYSY7VQ5Ui6NiQICN& z#7VUia*GY^zU&9K_i&$VDQw0~qfw5UUkSAQI!;9QIrrYLLH>y3bx&Q_|4IzkAriwB1Yf!S!0GK$rOInw1GD;CrZgxEs@DFw z9D}PkJx)A&II>gply)+DFp8HB+0eP=NAaNndD2+Ptug6D$L;?VYDAQ9xXfy>f6`j}{KV>5Pv>tWWe6dfoIhk9 z%Dv(KF^yPs~FgE6i|Gmb*2gQDRZiv50uL*xEL#RY)||9KU) zJ0bS_749&eT47k+qkZ2Tn@c7XEoD@*3c%hs_QqAv9caL*8#_peO1 z$lsFG-zRL82X{pO?fK z$UrEcd6mOTEVd$}2$vfd^(jL}RM$Dv>K$J#Sdwv8avbJ<=)zykx90N(lYz_(G8_(n zcbho7k0R0i<@;YB54!er)HWq>gI^*YISdF$u+(kzWxp00+n{GX4E>gj@~Sd4StPYH zW_Oje?nxZ$>2#y%l0uPm?pp3qWSm_udWYG4LR#_>+|qUo74$RQZ^=dJNnSgXI;GZs z(OPu8PPPaDy5;UjqS>QQ>lOL&T>e3(khxQtrgl_-5CPG8F&kAT^}I85I#@5s2Tiui z9n|r;{Xhjr>y!dC#OL_E+=yF5!87$bELe+ui06+{qEs&s6}_z>yHU6PorEq~LRp%c zotbM{VW!F40rG%2KD+R{OwK)`((V>B^(LQa(9O9Z#X>gWZJ$GYmTbgX&r@WO^+1OW zDweuSa%9($11-f$56jyG1>6t~0!l^v-mqc8mo$k0tP?usjLBEcLlym3k(mCjKxPy2tdU)e0K3@Dn zGDPuZ+E>)Duzz*znRKlb_JLu5C~s@X zw0s!%%2fOu%D8oAvoBRll6+;K@;A@8!l6r%K+AklWyuI4yH<6oKbE-%c~ zwf%&lSrgAITr(^kZi@fjI^mXz1kOTX01s-YDos-KJ&Qky9& z5i^*T^LP7dry|h=DAhIgfTQgY)c=i#mw) zrCs6KmfU2%ZzeGN9bZQ&zIB{4Oow_kn7DH>DJsl7xL{x%k2Z+~H2y3wQ9j5BQ)H@6(JcH7Xu3kt>aWd>fCZ5562RDF%wDw~7y^HoK9;cFGXG5lzr& z6)stQ=>AcBa5*hX@OsAn!(BBgeq=iWX#)UH?RQ$r&qoJ%9O4%{%r6*`H0JgQA+dd_ z+2-ma6si-(a9ku8NKy2r2Col_1fMgp%2to>YXWEob6~|TOL+WX!dbFiilB@JH7M6i z{doVE0^eI!vX*pA6!p9<8o99%oW%{j&0Ddv?^u4on1LN8M?!-?SnO!E6PSvHnQd#F zR3A|FdF4gdc9wc&xh*tg-9pGx{S;l&$@;nkv@y=4 zCFVmG{zhtUQlU<+_HJQM`q zk@k@FjBq`sx27XwM%Fpk68n_w{jSG(vYj+rJbfsL^>gm2lxbUirXOpa0n@$r(5*Wv z#-w5YMRIcT zKMWUgQ!hE8eG*8D3EjfaFU-f2nqyTcA0i)+#HrCI=w^Rr?lGhRSi>tffAqT?IE>vcbd>?V>4HDH{_#;6Kvt5n=>y|` zMEAlMamrymi`{GiRk&?GA1efocOKm>ba9DCBN4j(h*^8!0~MxV6Q=fPA8TqIvL^G90~^b#Qk^ z=;8aF(WnLPNBz?uvj6egfz6HR3P*Qt7CHUGv2_0q?TC7$YB_ngeb30ZF8A4r@7XY0 zL=58CV=fWAo@J7*!u1~4TY7nc?x4}P_hg)ltfAjIY~wB&JYH!l%b9z_IX)LPYJ6WdDxrWQGHUn&tE@5_0mHVLQ31Q*ZQY+Iz|VB6MyF zz`-WWg}}IFU)dM?7JVY|%UIB2tRoyk1CCGyN%f?IWK|_R;&t8)kG0Cruzg#h_lce# zwWZ_F>CHSPl%M@k{d=TVD3WfP1^7d^ycQyI?iN7~MnY@5He;`)XyIYl0q4aRI;)Qx zhzD$EKuM5`kWuP|=!kiSr0ksr$BpOvutllLA<`#F4;_BS-%~VU(jm)ir-()NfzU#! zq|)%;0o!|}3yQx1WjDN|>7e2f1?rO-YBA8gO{~#tM9L56*LGVFyU#Qj`SQMY{H@IJ=o>Y#B)LWO3fHtI=aXaW`W>Qf$qm zoZf?X@xkAZe*k6_j()08O(k92CFSJ6uv9%o=i<6RpI4wf-CkZwMq9@R1G|AJ=#&E# zsWQP^CSc)-+q< zlvs%5d~5q9G-ZhMHu^s91e7x?TV3PecPAP?=e`K!raNU6R896}3&T3!58imfcfXZ| zMQ$1w1DmQzkycPS=jihQisILr`T_69lWO2=`fv4N5^N}1dz8zm$omO*5~YH*KdL1y zKodQm5mJlJIa{(5Z8Ot*ZzjYLVXwQxAU5YA)V|tAxzj1s1;wjqEQcM`Hp_ty6hHVs zahwo`>b27xN2MA4EV{fW)j?MEJ*QoRiY4Z8|bHES&H zi(^RG*!(G8By?Zpo~8c`Lc<8R=k!QPKr7`GkoEV8u{)iA+kg+Ma1Ev35k#3@Gl991{B0wC&YR9Mc~WdQA^2 zP6}YOdy~%yob!|8v09`U9K7A^W$J)#AzYyjL09xD^cIH~?ywh_EX#ShYWtFd*FH&~ zNWU|*czICFO}Gp}l5lG;Sw15tLZ;m!T5}pz-Gt%b2Ys$4$P&B?P(i{n$mlqck~z@W z`h=!5WlV4PO>l-;7BAGX_}HjYut!9A-aC{&kI@C_@T{3~U2R@@H=wZ82pLy3>PD0-wfqia`lK@8U9`ck2bf$N&6aD%uXh1<`0{}y?$L8(WcyyNn>SLJ5lJSJg6onlGEk&p1qzgR_-;y2ICU`A#PLNQiKrDPD z&I-7ox=0qE{#VRJhCFMLUX&Iqd@}C~e%ivAxjc%re2Kq*eir%mV(Hlz$o=nnq|eSn zIe+%IZ3+))J?Q8=YTntYWAvC6yB~=O>{ZLYA$=gzK~k{b_)U+Y8N$}m$|MN`mVKW( z{9K|TUXxs6*j|EIv7*yXeci;YISrTF? z(TjKL)E9U0`_Jza_k909<$Z-)Rl(Qpp*s%UDP0Fi>F(}sknS$&20`gYx*KU}1OzE5 zX%zwKR>Hdpzwh_m=icZ30T=eeGwd^G_Uwr@v(|doJIXM}42uFt>vTcMdvg3(2?K@o z^gsf8W1|caHej7IWiNDwuBnY_r7vO1HXbn+g!cU49#N}2;~B=K0oS)ic5qKJBUx~ z$NvLfB>eA#GrIpkBhhVl0H_aC43$6T|A97od$CdgFvpo#3EDkwP@f=5!cI%~5Dr@P zxh{_Xk^Q!gG8h$Ap|tuDWc&nmqTBBP+;{N*hx{vm3kwDYn_+jg{6StQXqGBP%Sub& zsjkvZ!C=qulfrnPKZ^N%exMAuZ48ZvD2A_zTrqH?VP)z;NJvsjsL(&eG*FVGG}F@& zkY6ObAV$md&|qqp(enh4kbZ;y0cM07r2;ZY^d(rLFR1bF(4(0zhGQj7)4Y{Qp)Zp_ zRjjRiY&9D|qvpW;1M}qp5CWMs^4n-ZXD1f-KKQ=gZ_cnt0$-dx@V)0ScnyC0kek5y zY-{^4_%&uOxZCty-&vV;9Vum}SMLO);kuGDdZW8mLC~yhJIm&_w)UgVf2Ik5{6 zAU(Y9g=rf@LIgY|%!wsfTjkgKTAH>#qVcv~4waS-Bg7e4RUD2|za|8OL z!6I4(JAK4EIjaFuGm|anh%4BwXGrb*;_%y>;GuT?hpBRrQ-El8zju7>;Fp~a_!Ks& zXG2r-aP`{6viACYQ$|-T-^y8foh^U4oP`&+vh9N(~7EsnU9FD2TFyN?s<9&HvKJSk`w`Z#1c*pO4grI80y zLL)J$(VeT9ke9S@nUJ$gRnMVF2!~jiQEh2)nXrj^7W)c|kPHJJtOD0Y3+d_ivizT< zaFd3|&iKUDo6QRe)9)FMGVR0w4u79!4})wHo9yqxyM*!%Z6%;?YM3c^^2B|^h7S0K zmM#}3_zxyz56l2FOF{Xhr^unqd?}5J?13zkw~+`81X2W;1RJW+!O@KTzRqc}yu&^^ zyy_8GvtO}c;GKJ;qvs!&^``w`0F8BDOva;sWyYI8}wM)al)0rWPQR1^e^d z;!pn*aP-x)CyXPv4dB-KpZAx5`|Lj{&&ZsgAcR~VpS~cC#Rni(Wt$p0@@1aCe{fvy z@SBJcoSu0hJgPlv5N93v1pLr%&<3XQ-?xllTh~p-i-%kgjFrHqtn3S6oyxq6s3dIN z0%At>J&@(F)(w}QB?Djnr}0$>#5uofZFPGD`7#ig^y$a1ubcFvHMpSiM(D?Fg0p>- z!rmiT%MbM1v>|yPzQ+8C5C?qptkb)9`?0I1fvQ7fh zl)e)K12%A#qfka?TF_YE<_17h>>Z)sGoTyDzG;L%`Pg1n4~-gtwwtYFEF!)0wfo(- zl9mT#1+XauBo4yly2ix6`%=b*SpP?l+OcjHVS4Y7 zK=ao<0td(fOJ{8fbl>1EKm;aU^}+x0aJzQZxZCdXbKqUt!5P5E3#7LLeYFoWZ*NiV zF-bq|WyIs|aXY?|PmB`6KKK5Kj{$J^h1_$GJ!BHY1Qb{5*(Z96k?VQNtnEMqtWd}w0HyE+C5T+vH~8!<+7KYD8Ts?{#m_(}>N)h* znkvaz$X!v&16rqQA0VM9c#)d~v>ED%bW^VGR?n$2=czI`ex}dM)c^P8%mO+@^WBir z3W0{?Pkd@oFtU#4HE-zsAgbb;?ybDZxgQo>yQ$7_BX@=|@ZS6?+NIS`dEn?3=1)w| zwJ5_l@R@g}#HsSLlYiI4(}4=yU-%GK)Bma_deX1#d;k0yiewL*9!0!Ce^GA1QK(1W zt|AQ^Y5}=fUjkx#(K1=t8q?)`g2I>TcKA1KW`zSS6>39oK^60ymG#j!jxC9QsP%9p zj^;3&hYbg&At{_v3#=GYrA@?-_?#Vn@pLf)QtEkL!3Vs@Xzb*2d4_dmIll2)iS2jB zOHoi3ox<5j%R9lAvN^-zCOhWTx>D9l7+VxBzVMg%nB!4#JHD6{t)?+O2$FBUe+@xN zFGf5-?9bZ}@8LU!!zG}tDNqV_#It3U8E0T>1iM7T@^Z{;CUZSOaYXNjD}c;3ctU}W zsHzoi4Oy!&s0bS&H*t7!%Il=?&yj_6!RG-dXO(HIHbD_UNkMsf#=DDJyLV8%UuoT` z6%pF?s4=Alm)A99IJSg1;OP^9e#u{d=HRwdK(0XDtR$Yt&gpx zMSq;5P_DXjWbruB$^aC%D1*V$n+#ay+97U=9(N4Hv`^a2jr_8|a|qt0}g`Iu50O=m+V!+?6QfYXw^9>nf0 zzc^(#y!cM({YLAhU+*u2RaCgi^>cOSV4Xyb1mrNch=9YuI<_TcN7ADlOi|>wnlc+m zHC3R2ba?h+oGmRe;&_O-+SH}Ez1W7iwU?H1A zhkob5rVJ=@eXZmo88yMM`1lH?leozH2G*=t-wLr>sCr4*-}yZs3Kx7Ae|g-_W?7N(Vdkf=v9h^?XDd$>#7$B^OTh*K17Bjb@JhS!K()tcq>)Ht)il0vIK z(`NAG4K~b{5_HI-NE1`(g>u03C)7%|bdTkhHXEDep_K7TIMA~!E*46LYp^%rkqJrL z3i^V&v?yb9gE z@cnYi{R+u^K3HU815frFxo}U?amHO@+3qU1j6mxd{q^uwLBqe$U10cP=ezAJ*Lb~q z@Di|2Nb6X2ZZvsB?}=;hrs0+P zVWubPk7FC?KDt^L4ngsI4TvMGkW23Qo(6Xf00m8h;-c+b(h9*8n@R7{Kt?>EV#%~B zc?VWFD=`eiI~(HX8=6B-E6FgRl8gveOKkTTOYD7-K%w6=xSXwcpgQN^H*slGoijNV z%EE0|#6Ul_JTCaCo~V9>Nf+P2c#Pz(8Nz3))Oii%1mX;?9^)yW zvw4TnR0GO19QMYscn<&uJBElxy9p@JRC9aOlWahzEjy$eHw zyO~KDNiF4EUWiSd;=Zx9j&@IYW|n%Yqa4h*J8!1j)m3lj)k-O(PWsdj3nYC2fMD)H zRVoZnG|l~E!;{fS%9S~(Ht}&n%9gJN4TF8ZB4zRvx*!ogq$eV@E{|iYP-Eq2@lXin zttT;GUja@>&|Wx_Jl1nx>0yO zsUFAP`u5miE`Ui4*oH0}W!x)#IZTm?+Fb-wnN-&V{DosUg^{j^&_;2c^h~NMKA*0l z|EyZAJI3l=XwciPT3?Wi=a@(}6X-2CSSO9(%2SyVo9ZQsO_Zy26?0|2vxb2(p=nf$ zvK0X!=+-n~dH6Ka^}3j)q`kz5=HA{=|3Cl)qtyT>3X&@cPvIu+RtDH*OcC{UdQPE2 z>?aX3jgGH&3U0)=i*6}}Wki}qFQ{plf@8s}Hi)GoPq>tC0H+HoMj`vo198(#O`^ed z0;7Or-3}rW^L7#_cMn02^^Y7E4|;l416q2Q$DYy!rzi$(#m8;)=3nT=peR%|%;eN) zZ80cjxMf<_I!6Im!U9su9~y1mQS}by(|iz<}o{@d2@gyM}b4J_;nMI=+Db z_yLkoLoV-q-<~!ogi$RgG^};LB->Hk@r9Jw3|GSw);dw#qL$xrrt7KC4``m{v%J`W zV1{@mPj3}1|Mytwi9o3GRTBL3V2n2#_F@lzb2?it>RD3Ei~@_}h{B*T`)(Q;a+qo+ zAc(s@bYSn>+XaL60}WhUnZxN>J1MKsrNz5{KJnynMKbI#Tfm9^DD;(H(Zz9Sy>8}A^|_|~wZf`oKUa3h9wFsnk=quDd3?mn7t>RZ|4 z3jA5t+&|q7i#BmhN6Mh1Uo}RWm)M`G;m8hL5KPHJO0)HABBPdf&v)ofon=~*bn~0D zS?`?TTbDy2ECZ$n7TjrUahGHW#vpu`g02QkAwqIlqG7>4mka&=izSVB{HCW*n$+$HE<{7i!L%Y0xFTr=y^Xz|CHtsx zpWY>8YPYrcu>{4}I%^+K^NL&`><%OO39T2G3AasrNjY>nX1m1D#Lp9~vKzJreoAST zWReZ6RB3n1`;BMQyl59S{6VwwBzyW>=%!Zd?*(;whajBuk}>AMDg?>R+q6hmE0{^k z1s+RXo#aJ`<~LO;(aAZZfcr;|-y#F@x5`tAGp&WRYvYD~af5ZuKd9t=;yRMv(Pnx!39YTcSmRlm~ISo1I9tID7mJgv0JE}9>!tgV{EvWKMCvTisR zw5bO6?Xm0uiHOr+lxo^-O99jZL)4NgHN(U2f+_dV;CNw%L zsTtL93rH4iti+dSPg^@1ECTka!8zPd>QOr{#r@W*oW5%i+YmMIi}MG!sAqIu=D}fl zrZH6)nKh@5W;*HCBWUJRrM-|kPPFC_k?iFExATs)NBucTatH*~03j~fKVCv~JVSBW zDGB8xvJ8r{cv4gp;Zh3@xz@s@#a-$9k)s*0O^`=t(-MP?Tg3-1!dfd{C{pp^Y^`1b z@4O1VX{ZGf*VWav(Xf1x_1dR$QKZ}}O2YD&S_qS`=pkD#&q1RYcpm{PXBx^w@Bphp z2ou{w+@#ggs+s^agb+SY*WMU${VO@+6)cnS)6tax!Kct9p z(L3REjMhs6!KAsToAou$F)_RC@^;wjU>@##%0qoU@$&>U6Hbu<*zUcyeK|BrsX~_LJ+Vt0Wl_?t{sqaRWa{`NsS1Ya5py@(GsZY&kl5`xP(^6>O4^tSh}2+ zW}((jNZ>Nmjpf-mWnm03xRewaoujNora@L6{p_Ds{XWM;pq06xHi>=3XSo{RPLb4v zh1)|E5J5xb9leQD9$Or7{MR;yfY1RJz4QCH!R5ilzFR+?FANjJu=>^vu!=MkJESrg z93G<620u|UITm6SC{yT?=RL$IT~^m;CCV}zS6Og|+^`jhib6k%TR_NGh)ba9I@^oJ zHWG%boPNrzx6rQdR8+C6mMyl$zVWe`7(04jIJ zomp(^8@4qp!PE`+gZ6aQpA)Wyt*AXg2d#IyjtgqUL2yX3Ljj?@V@y3pEocv>H!12p@^A6u7TMD8VABIT;nVo~o^1W54 zcH|pn|G|@c6!=U(Dv}}DL+>LHp~Y^_+@Gd&QdoyL|Iz)U)0&dbJk#S|+;WKk_<1m< zDLstre!s(TUa}@pv=I_RI{0T+>8}T9ddDqSP$`1G$h)gCj?>LW6ZA9pd!Bp&XBC$d#*f>Qu_g#WsC1_Nq>K}P@NwPrrw*F5{LxEn3q|8cl74N) zl3Xc1_F_bv)Q=g(9?P(xK@Pgny(JsOrpcS_R|ww#9QQfL$)cj{1M3s&+p|RcULg#d zyG6ZNpwat>!d0EVxi*3|=Jofh?KuY_)#kTdauVNLp@RrQiK(w*CF`$Ig*$*5ycX@w zAw>&#$j05PSXf>n!`U$~ETJv0`mMjBWifkBJl=-zatR`tTHY^>NyLPVc~a8`$2vVK zP(%E*yGXN_aS2ZR!}RFnp$+E=yz+mewp%mwY3};#1VeLmx84@6>BEd~`Um z0=9U-UO)J#vn0;e5ZX2U1(`x=G`bed{ojeRW){^t?=D53+#*FwTu1=FHHlM zWaB%q9+tOd>1a67>uJX%c-sK80_GskX|gHolBDJ!Su@=!*h%O)z32)eaSOGak{^m2 z1#_fQGz@REyUdtmZQrmc`T^%{=*+D?o9rRgIchrycPZl%JW_=EM2t)*dC_p<9%+l- z=o-TX6-)g%^7>nzLD&I9>jTVk#5AB-u4l>mkbYli>RSIJ$G4zdf|P=IB4o}J$-P78 zIM(X;U@prxEF;E`DB@l86|VIX-W-Ofo*=YiD3wN=T20;o&s1GSS;I&%lgu!MUFdwu z7gz}3)Q0v}qqTf4o1~)pG;!`!ClJM;6zCaMJyi}h%a8k|zHmt@p^{xkx&akzD+rZ| zqNNFLR#Ky9;5ylwTYOp{`desefq+^gz=g4s^=9H^;sgjOuXb5a%SB|BA)_JI^HiWT zx2YVDp?KEnp&9%+tc3NDrCCB!?jb$y+jK!F0fKT6w^$Rq9!*Ss6iU2grin8U z7fj}hVeMRUF_wg>QRTwc%jva3PNnHIpU1-Qe=BGZ0dW5w5pENtd*;6R=o$?+@0%HvE3!az5hF>|;WRlgR$Fsz3F zR3?dM0v~!L%gnZAP{_&*sI?0|SkU(_ZA6Px38cik6Mz<=-e|00N;Z}H;}k$w?I{)5~=MH;8&OP`3`MC27*{lK8% z0zfB>1RNlMM>J(S2buso?TWt8Vxgvfn~06*vOt@qc%{t>bP<=Tq02V3~_NL|f z6J}W};_ol;WCM!MojfHA2Ge${0r}5|ZU1mU*+DI%P}>g{hdHIQ#2XR_8VeWm0@fCQ zr6l6k1*F#tBRiKKg+S+{HM;3*OrB2^tl4j=1XWb=_Ef@Z&<+djmD`RxofUzzg(v%R z>Y7i85D^A1PIl}fbW#eVj*4@+4h3%X=2@0*v$XY!DwqAdR8)i@uI zx5c6<>W3^c$rN@&dOoO78M(1Z&^xMju&?)6j`dXwz4fILNYg!(>zR#@phUEvjJPv} z8YL2!Z|JVfjsV;od;xovFr_C*M>;l~x^C{WRZE=XjSIeqI1n!0*o&wb8eX*Bikt>+ zU#m(Q4l+tS=Z3-Fh9ew%8;Yh7Cj%z?RyYx`vxd|c*U-*^Vqf_pt8j|4VgmfI6)AC% zDGQoS>(orW-iW1kN0hjd&#ml0FNwGcLU_>gKITF7yt4I&_K#cK6ZCT=9uVX=vk_fY zo2xO&Gy*qD)GE9-EZIQ%>Lx+at-bo-vMJE z24(zjHsy-n?}Mg@}9i!HeEcHtzIOkw@>%L}3?tQ(zda*te+P?~ovfk5Py- zL?@peycF!61jD`xFKj?6l}65zFLzzvOeX;ArN_*KQ zVde#G&wxWaHi>Zmf)*#t0+>fy0i6KAhNJI+;9vpClc#K+x%U?o!Ds!-8-$D=d^Z20 zecOy$7C6%jPK9xlNi!51yDWg62#d0&Ec(L@CT@P4*p7eC_U-SUZ9>%HDWe!3@Opy) z;=u1$f^Z8FV+XkSU7}t>*iNXWGM@rSx3zG&`X-RpEo8laCj7f1ykGubF1ir!$BS^| zWq2;(dM?stF2jFZNb4U#N6N6r$~eaypK>jZIR-vs|ITI0*!-RQ;@i_@aYC0Q5dB@5 z%16ClfTQmYCjy_~;yn6WX<}=FEZ6D)|KN&!_q~E#H zTi)cKj)XHdUqDO|Fl1FwIrsDeoavKrm?O4WXJZv?}jbe?Zf#b`6?LK7adnmr<;Y-!V zJ-=v7U2iP|h=99au9cgvJ??Ww8}jvi2okg%Q!Dnco1QHX`~@+7_zMcT|F`!07pC7UKn{(Xl>5K6e?j#GDkxgfKLcfd1}cl37cb;} zV|U&deTk+*TDQdS(0ogXiQI{OqxFo@v_lu8sh01R3;9hUxX4KEj`lohk^;(ZkKEHH z&t3nwS*CD24@F1c&J9#l)Qgal7(rF%z_%-GnfH#OGJCHC>l^ zHp?}C^8GLt@@}ZPU_Xp-Axy!jkf{a!5Oy6$CO!J?bRK!oVJ50KOU7;z#ix3CM{A*n zzvpa5-57;Fcjz@G?6Nk6GzYzV1h<%?Dmy_=d7JKEyZ?G{qj7D@`gRrs!XXsTTDUZ? zsV(?DBuv>mR?s2rJto71MuJ9~qeG9}1XoX+!oMQg%foXc-)DES={YAJtfE?9Iv^sL z+Nu$??fVrcS<@jC4w$PGrk5f>_MFj+%KbuYXt`IR^L>KUMO&YVif}mShs4?y~+9q)VO1b?9rl3NNV$ILg$E-B*;OU)X@?Ff3wT-A` zI&_MBI3uK5sQnIf>DN$W&5=g~QrVa;{V}5l{W$hrRz3Xhdz=D?Iw4n1=ZSH6^@Z9& z;s^LTDdSLH-Iy>c&6>u2q+QXrb|VOp zxS=+LpM$EQg^>$s`=UqTV368XBZ3mZZ{)dhh z9LHbAW)6NwSHqV=Znu`VFMW=^ZTamC=P9_GY?8@TZ$FWxBzk6gPL3y#UOxA~JTq;R zY#z~FI0zOgvI)w^bw@H2_4{!1yp9iH%b`$+j&4Lb^)E;#u(0V$2dd6|U9uii7!ti; zL`Lp@(bv6i9LI=0{Ns*L61wQMigUuL?|-ONhgX$Q-} z=V`BD5ecEyZTqxAMkpHA=tT(E8p0;H830nhaL23X%BK9Vl2+K`9v;**OU$7}*HMYt zoQSkOzm^ThT8;3YmzPvdX>0=eGn(M0n;8TI9aM-wRjJHohFH30ADIujerYEdbA24X zxuU)|2oq^+O@-75`hl>4Vv7>r`KF=X@dG*}KZiUh%jgDR8`wC>Ta28zTXdM2WHFPy zEsnuliqBk6r5;Infg%b+@Pe(J>6}MMYYa74jR09TbRE^N*aDR z-jOi!&w>B#n#e$iAwL!cr96*ys`JS zaA7j9=rA_LRvTmS$h6~f(;o&7YCw2ZKQ?b~XvG;330q|oQ*aKQY&Xvwr_AOva?mul zI6@dg8)A%B)Qmt$)5SLotJ+0Or2!C#9+^hsFUVx0`LO>R`6~GfK$;u^(q!GdohERB z&v6*vYmKo*gEr`Qd^&~$K|W?Z1D!>rvekVX9A#^6MCJLze=tR(>*($DZ7p2TKMk7O zHy#mkc|GBLFUfZKO^?=oF?i#Zt|8~qzHYh{TQf(2tjr%R`Dv=|Sa|1eFhNO3V!tu` zih_4H0qb!4iA`JlWm^a~uy;5LZwVr|QROQZTo%?{LwHHPdK_`+^cMtC+LQi@V6{1h z@2MWeT8+`*Q@=HwMP{!`^}3<=C(KEdTv910O5Z4A^Dc9}DF^&}3z$Sv^oG#48xlm7 z&4&{>l9P4^|6Z8k;f2#K{EqzbSP#1;G3S{U654a(>jj!!TTB)N$NJ5`3iOIK5*v^E%zgdph8} zFKjQ+cq1Wj%&txZgaZf5`1X6|`;g`2u5*T1)*l@~mn9$k@jbAiQi7DNC=QHy?a1N& zf(kL_ym}XhFFWu1R?moR2r-14*N8yadVz* z3+hKx?C6dM)FNVd1WR?fYP15r@7vCwN<7m0Pa1_8qh6+Je;_5*~KN&IjK zF=n7BSYve@wUyeiO#}o74@y45{!UPtuV2wh_=Su4_u<2kIbQq4ZYSJ`%oPLO0XOXB z{-K|c0Xp|D$VOUpkECh>$?U7ltMg%PzUB+hp*VMIKO%y_x83Ly!d>6cR@d&FF$IEP z!HdsH3hJ#UD=I2>>#SOLmg&~|litcX4zo42bCR2*R7>4-^oU7Y495DARLwO@&^amy zd2y+G2%|$P(c1~UT+1&ZWe4rKL;Oa2fN)q>c4*w|#grN*w_v4PJnTIp=;;0pg1**m zg6`$d5OEO1`G%2s7l&ZI-<3{NH_ok9mWfnPurmxv< zdL@hZK3oW?`RIQve(%!SoAMhP*GQ>4I^YPkkbou)ht^vL3m%p9vdJy>$uyzlf6$y9Df${`xy!4}Em}c)k8pq?SsLjSj z>UX<&{}=S~O8IX5q}kEi*6G_qq`>ULoem?7Cnx*w+U@=g4$+ytUGZFs$v11-w8`g- zvypsvGA+ZutxU^-&L+iDXiA#A|GJA?^KVw?i&&*1n!A7_XvMMN^dw5O|N zk*8giw~KyBauVwt`svZ^E`vu2bx;~f4Vv3)iAFJPHCuaWH?SBPT>28A#i(j zt`_G7uxO$EX4~%nCe--T1*n)Xx`j; zi0Bqfai$7f_A z2q8OV>NGhi8Tjg_d%xe3nkp?bMRuCpSRFJ&8r+>FJqz6AvdRasyJtfxDDVSqgQTz! zBnN?$ASpTUMJ5v31<8=)q)0*Fwj4AYd?A4|sTJEGXf{@c1R74+1}=lPkkkn&_*4Z2 z&Vy#jMaqy6D9AxyDJT;3jD>p-f@DbGIS2|upk&~_4FoIC9`Ntm8zu13}!xDp_f3yFYHvUQfX8(w| zGrZ&f-uwqaS`o_cq_!YXfLmbJ0K|m=VIdSus~v!7Fx^*-(+Ut1+BOZ6!a_e^BtZx; zYPjKk5&{K?4MEFED&SOV3pfW{aCe*`K!d4?uWSF!{-sg_<=+L70G0p`!219^JeXRDCB!$H;C&3I3H;9Tm z;|@RiZ}lIk6(Roup8avbg%I#4W<#4{Ty6oRV7NfSKj8ud6P!W6f~G)NgW!fwASo;a z4`CM=5$NV3IG-&A#tjd6TsD^{zO4B-`$tu6()(BYUrd)hhW#=>Gp}VfJl*B&R!4W59Vf_m*5#Le(C2jGcXPp0}un^=X-#AF13(VU3 zS&RyZsPCnt$7Vg?6Mq%UOgez=o#X~0!Y-b#`5gqh8%ycJ7NW!Zz zXodLF?ry5d7r~sFTw0YoUugPqbGz)zF(`_)UX6k=EFXh1i5UMd+${0n#V#QPWW3k; zF=2a_JNYJu#vFsTxtfqi2EcqzgLz0s>I4Gj#m{0?<*5h0e6j>N8_73EwBKWXF23a+ zJ6t$tuTZt|)nHxTcDxf+S#=7Y!ZX0c`|3_Ai^_Mz^nv*fsjYnR;22c*KK)7_Nks+% zpnlwfg_N)RJ{U-2b)FJwaL6?W*Cgf~a_1QuZw$KD z7-m$sK9`2H?(}i9iyf|0-6xb;(T#~BI2H5o@mM~yGyB?c{-wqg5k<3L{E-4-r8 zglT)7G$lI`Uor;OOEl8F7dNfY(ogU;I$OId zz56TH8ekl*No)lvzLw6r5?V&wD>w#znaxK85pUPT`C4DBBrpdxgCIS{PFx6473pwG zLKI*grV}sJ=Cz*>{Mc8P6;s`5Wz&uFUdJQ9swYOr4$ulQNB8TpD=r&_MdO`li__eZ zciF-Sb&Z6M%Dep)I_zCSa(hdUn|1fVvoWZ;{OL^Rt_|^t!$E6`ueF;21Xjhq5EwXp z>~Ff}8#55emHjXVmG)nubhmb&qn8oa1)A{Lop=ugpAoKGma)gE(zECIdLCzZ!x*G} zGBQ;m^}X&GRPIgm5%v4_jzJ4*ed;9st*5=sDCw^9R|JZCxb9>Yqr7t_>qAJ|ORjT0 zqq0KOc;BxQU46g-aP}k|Fw@@eIZS>a%u=njXj~J~LUe94Bb#tsq&?EK-ailM15BQ! zLG3V+J|bVhondsiq8y)_>^nTv;IF;oQe|GjTjW|XZnj_-kM&Y$AN8#4P)v=6RYJ=L z`D0~7EXSTn+%6w4kYy|1XJHHor_pbHTKejpA3U?qCTRJ#^fgT9Xw+tuAPv(9QGCte zItivY%d|;%wd<%y2h;Djyu*U}?9z}e%g*-SXYdkv*ztS4L2#rYE5s_KCw=Q!wK6^O)$0uU{ za_2OO0?<*;oI#!rl1+^63Lthgo*cpyr>iqK#S01=^gBnHeR`%%d=^3MGk6*KtjlW{ zR)KZSp(&Dh6WUqdeVk>llkl{A8xpe5%9apKc+5)7a_ejyU9#Mbmp&v|x=*Nd;Dhf% zE1^+=4%bv@&wfo6w9y-hW+JcCj=1&hGv^Gct`v-%0%xUNJxrCM9U$wUd+YeJTi@K`j+N_XC!&x70GsdGyP7g>mQug zPk|E%f=IAe2~4G5j0cr;j#-A;@V-f*G)KUf?bVqQ+IIqdQS3j6T}(GT!ZmKjeU{>15xPB!8)apjzp}WP z`F(V*$&jGB1uwg=maF%Xm6xs8NxdzYer7a!%40G0_7JiRWgby-qXDx|6yV?O9bt>t z9vN};HRL#Tr-}&I-xFwWXzJnhWpuh$9S1KN9fNvLvEHzZ2);gpwqbpMb8ZhQl-)wd@UR*)V_L0_4rvtMPVlFl zQFwG{;!on}L!LgPN(IBXciPGfPKkE~fkkI&iw!$yz%S_qe#vX( z3b7w>OH8G`hR;nmY~r)~+B&<#HGCdwJNucVOK#9{TH;-sPmV#Sy2%d+HXY9@KH@(< z6n6)sy#$Ab>Lhx#EECZKW|l`iU%Gt^dhAX!JT!E;7jsGW4n|huk1tBH6e}3#wTUFk zP9mcbdJ&@>nI@5wgdW{`ig@h7FfQSJglyRuba}}O_k>D}N~4}pK_-qp20h@%8i}Fx zKsw6$(+EGn4|EZaKGXsaP2LW_|o+qC*HlRT+9f&`EHj1LL8wuR-JCd(Rc}Q~-{RGSS6j3nmSEB@9HH(P$^Bt-+n0X%r5%f^1ym+LJ3K-nCm$mp& zD)k9hIY0m*P;sJNV^A8J%HIe?08?0}(2-|ZO8Z42=Q+^nND@a(UeCk?5}Hc!0i|y3 z!+c?^#E+jAQiV=<%~~z?G*~Mne^9G-DBuc#K2MD$&cQ2R@bw=wxYm0jr!!hy9`K%! zry|X9;jQjRx=U^DWPO(DSIA}J8~eJ)pzShnSgK{Eh!zz^PsafijA0c#CjuNVD7BQy&d>n99Feb z*buAG{B-&gw#Hkcho~rx34|J`K5c*nUxSTW#JRpI+Kxv3@vVIp4Ya zu7iaj9p4?PA8vTk< za=%F&YYfsEMvM7PS%c|;QhbFT5mCWI$bMaeG!nfX-Q<%~0i1LYClGFm(+4isb~xY{ zaB#Krl(UBt?Syw_BZv2zs{Bf-cCeho0IGiPW=S#zHDDxm$wh>%M~$-6Rr?A)wi)&bWVJp>4X!L=n4E*v}@%{wBSfo90Bx{y1(8h`<^My}WZrhMD;XahXv^yd#Rw zHTNs_Cp_wD?3aogKwo)@0rr4Dz#gLX(k^af8Xo>YyD51G2a4CU8%hnNg|f{#$bB{3 z^}s`*ANO6&VHx(}W{LEz3SYkx$iHnENT(e(t@75BrLs`sCvii*l3Ia(E2F{z*!tBc zF!%YcTg0#9aCAIb8*8&TciaIQZf=Zw+2G009Luii>hFp+$+ zB!|WzdLzi#Y!~JDQr{O;&9kNr5oYnVdx)VF53$2wE{O9QBaLqx&Y(GiWKe-R|6}umN-KvMFHs{%rLjE3gB~ z7cZ9GL;9R62WgQ{iBf3Jp$~9Y8xAUENYfQ-%d%8mM=uMzPtwBVy(v}#oK`2svzPf; zvJ+4&PnmDu?IPgoHZIG?FYm6Em`3!D3i?eO9%?y1bA3b^(O~+o?PWe8?5y#LP*uVA z4?P2c|5ESabMmhQM(s1qaAhBo~gE1&6FO+XKYE%50wMvYawSm2%k#s!H zn8Rc0=V=Lpwy8T&+mw{DM997tYY?%ibzmO&T9c|MuUehZQLm^_?EEafSy=rn zhG>}q2gmIJibGQYzqjbr4GOwD#-Kae%)>jYy{&tdo{BG@uaHzb^kB5|+*#M1(P`lz z8Z8q&dwn;>nbx6ZtD3EeP>;)Vo z&lij|GnUquW;$#ZFDmj@?br^C)OXU=pB2%HsmU*EL0SVO>)^!MBzaLdi}!oRO}c`X zH3p$E_8MkP(*OypOh5e1Hjo1x&+jWO$il070`FaTW;BKy%C8+HHXZ`;KM9(Hos9(< z>Pd5JAfd7w4z!O0EMP(SD~k#Sdcp)Qn|F2?KQzkf6@zdY`=43h3Ro?QdQJTPBE~I= zufP~EYaaApF@Ut<<=>4vbRdy~fHT5UyTMuon3VAZ;5c3&-w(V!#9Sb{m##KNi84z8387DkhfS+zcV8$lPz=FH8Nzl~Pm$j3ubs&aaB$R15whl~O(EE78 z3!MG+6~EuL*ba~r9UsLXh^J@N$-1p8?L11;=%7aOF>KaD%vMW-&3Oc$C1nr9Bgof{ zN;5O@MoEO*{8LY=CFB=lkc#*YjEvOgYRs=922CKa4rn{Q#E(m^c!6lb91=N+InBT? zkc5Mr(F??<6fl?omH*5?7{`An^e1d(;MnXxgq{prqK0uLG8-_v(Osm1cral|oLDNP zyzyBI5=&3Ri5(&Z;3L7)k3g01M8qBF#!*$F?OV|x6ZWfys=SDLD5nUEc;@Y(woaQ(1~W`$qrn*fNMyg`8iK`$ zytPUgN4Je_gUatf8s}*YPJofY2lI}AE@mUj_0nGg^+gL^r{rv^QOniQU{j+rV~m77 zq=-o07JjF|^}`2Dkfb~=MX+0&tc;pIW>~o5Axn}byx2f zPBBjKRZW*f^OqNnZ1~A?G`lLA?U=KjGX_P2>bBT`m(iR zXVZ`k$g%^#r7o2rDQwB3k6wMr+YqsmPwZB&0-jIfoPq}Y;AnLpYIB!t6DEQhgO-=( zEA*~nyb}B$C>OcQKA{dy9wWelIHJOb`NF%UgL>D??-#Lj0o6dPT3%0k>CNq6g?h@i zVv>ccJBcA}`pu*1eF3qwc)@la^ObIb;W4gHGc{WePW1GPY0aXS_X&RNXL`Ni{9#tU zo)OV{akUYvQolFsJi$n)7nP3ujb{WV{G_#YZD zDFkabuhVs zBxk#Jjc}PNBJNOvW(SMVi+m*6Kfc7m1;MmJFi`b|U^yTcD@^CMdOa8zgIJ5lpndg9 zU-;p~?#2FfUK(s#v^LIke?K{%ped z)s%2Ttjz&)nJ&WJ*L9oMyHW%R zmm^9*hTii0bGaq!`P8nb(z}J*W>0rM#;1CS?FZ?D^bnZNQ>guTZePnQ+6uA%ZU>^N z@*g#&XpLwJ817$RasOSlM`EF>EO8_-<)11lych5^-lVCfKJ@AzWYtOhKt^U4@Gsp# zj%PERv1$i;H-Ds4fmK`%g7Q8V^F}-Q z4F-Qimr&DT^_c|@90Z}&G`?!j3q5W(?OLyb5Br`y7xR8Un9Ca|rC9J_L3MoXV54hv zU$+?JIMY9~+0lyiLm-?v9)r}yb_Q4>om(-)emS1rwHQ4-`j^XjfF2yLRe(V00wi-4 ztmi&EC&U7mV!Rv)q0yV&V(bx6ZjZ3A698Ea% zG!LN`wQtM!y9UDtQ{IrI7M&su4A+)4qNTk|s1+$4tSKdA4R?8D;w6 zBYc{d*m*FawWZhV39MiiHYpfZj6pd>1B?lNi7W?McYx|oJCFM7RU?tGSU|w(gXHE; zgJ#bhw-aAHL6DQcl{3>Na>edBPm^B=R1siJ$+s;Pe=R*Pny=zwR)jGt&7#yoy$XNO zg}2Z|!IpjH3F(9%YJ_>SmKboD)v%`kglUH;6<*;q`ar%(D4OlTswOU|QyyV%VglKJ ztYp}Wnl5x6O}F!1&xr?#D?Dp721LK1K}Zjzk)(Rm(7wOB!HH{HIW(2ZqnSzuMMGkp zfitS_h$jzPD@$O%81+vCUka-gK|6&V*Me|ehSHP_{K2x3JfB}rQI9-MWjz9EYP|)W z7>d7=h&3z=mW~mnKK^gj#vmVdWqa!IDkC#NX`(?mGDe%q1OegY^R1`vaUY4ZPxN<> z^fe9!s50tx!nTSbS(APo)kDMd03dYgL1l$fl9FN;e$^|U+f}gQtTpqo_!guAw5@ym zOYT)T8R5m)Axu9GLPz>ARxIg&FQol+;GNhRf33b1_ zoqrNv{KI)1S0i8e=YJYWKsT@?1PWw7VNYf=th)X;Wk-Kdg1{&&X5v!+S7n3W=ot`P zV720DC;mrekCPA>fs#Jd%{wmx=IYM_NYpu0bGC$==Ohuz+g-c284I8dH!G2 z_5IP^fnyIyMH&PJ$AR9P{lBQYo1j4<*oFc}A_)NcV}TbK*jI^+f9Ah}Nr8jqsj1a) z;_ly?A4m?U!{h(r{77w%8y5bImH9u{{Z1+XQh+ysA*=mg=Q2KPpm91vAd-0ZF9{d| zs3aLbIB@-UG#o4h2o%tBNq=Ba2}e>u>7{E_(VB2cSYS)x{0#&eqh>?%P<#~fI0=CQ z?h*--x<~@D5AJ}1U%=ELLxn)WZUlS*B#NZ@)B?;hI)_be%OI|X-3ka63RvF&$$s;@ z&{0k`_c-0Nti|gPt(Q=%BQOp`@lk;6A;8jDi$`#=nvfGVtlAL*>hf%q&O- z!a^j3YyoF8ZyH*m&_Ja?y1Qudn$bHGZw`+hgY_W)EYwKU)0q3TEARd;QZJvSosunJJ28 zcQHRcxKqwkV(Mo1^Ur=t&cS2+l+}z{R$MkTTq6Ho_q3g7YVnF^#VcMQNu!v(gnIDf zC$JEk-UqDx_YRa_tHg*y;ZkoC=h8w6MOU6HCLW)!^Yr%i$OR`OdWTkH3=77s?Re@B z0>U6gO4E(FtQXt(I3Op7u`q5&T*A!#lKHmrIvX)fq`wFKb8C$LvD#yajA8XE^XR7* z{vUMS#9n(S7PHo4`@JGzqXDdv>=EDB2zEqZ)4rLsq?tx#>x z{YQ{N{#Aes#C;6(xlm`DVPXGKXKt*qsL~g+U`kEbIOL#DrAgc7%JS%XtYV%{pDk4n zO5HIUQ%I^hjbx}15h9>DzIPyoHHJdPR5?)}X5;tTfVkJRkH8hQM7-C`n2u)^W1Cxc z>MEdh{i+63)v$2#Yc)`*<8wcsYFu8WE6&+mX21XLlf82bp0v^9YyC0n2EO0Bw9tk+ z5=w9#q^3yN>;`iIi)(UpkK4+x26u5>IB+u1(clniLI8$$HKmQ24%n=2GO$d%a ziFh4#P%&Ejuvj!SSJZZTZ@WK5`LId0DA>%J(A^D+zUr*ksKAhqY;e+tab_1f$Z zmBMnm-iey49UtfXY%6TCi)!RzK|%%&AV~0=5E`cI>tY8>UhBn)?Jn-GUd&Wmy?*~X z|H1Ef3}LqFQ$wuG>&Z2QrF%BE2pWi&&Hto1(RI1g+j|z~oWpzjx9Ydtyt_u;`iJzY z0W1&^7>)CQL7=wwrR=1WhW_0n{C}4Vm_ijcPtYmCnIH=q~Ir@s=oLm zFoo_wVx8?(&8f{NjW`%DkvfK<2DcFEp0YlktZ6ly+jgG@Zge?mciSU5ehkuo4vzv*HxW*CXqU)W_jL$}m3@Ba) z@|F%u#>hRS{W-?d_o#a@M}L5VheNpp^`EQhdOzFV-e7aRXpbltv*2LQIoKij2}CK! z;dSnsE{`#YYL=U<6ykI_$!z3@eQKxH8?9xxe;&sCA@7sx2U%)r3wtteBW$;X>>W}` z`-z@*;(fQgj*HSgoQnkex#{N*%tOYI4YgKaT8O8HsiDRiEw{Ocgul>>c+39;eM?Saw{2>KK-bvWUZ`coC zP|DRD3SKe_G_LMd+h#lbxvS1oN7Rc#Qxm5DkhZW{h`FOC9Y4s3s!B3Z%IF%7MXK5x z7Z&tfWELFuk6*}cZu0DVd)zN6kx?cWQ3}bj^?zVqZ4OMZW$&WrwjVm`lMqooqtYXe zS#; zR`a`bO7NYY&+Y#BAyrGME%L$~jb6TR@Gu%vczUqsIJ0E6zhzPkW2zRnM3hJ;S)J|Q z8hI<)@p2krUqgdLoAMFh0m zmbTxcu{d!k(EgR#;$<5RM-t8$?=_RN#6mk+E2uPt7O~IdJ*DjY8|^0Ze&4Zwfwa=xD&H%SSqO zPVO&Vec$%&tzSuh9N*_}({z=snW0DKTu|4YGBaLp-q|064j=H$Zz;Vwu+h9bQaW*2 zs{>|ba{Tk4bCo%n_WK!(gKlqfp4eHwkUkoC3)crh^NMe6!yF|tA|E9jESevmyjQjd z^AiLqblL8Dk!qZD__S?A=+Pa&wFRGQ@2+f0zX;8q1-$Z?wQ$Ujv$jvYKu14Z6RhG; zw6Z9ArLE;DyA6^flcM^~#m&M6gQRb+Ld1PQ(?7C0+n~vQwnRIllA&vu1WV4D6X?#urpmP+mbOqbx(=iv6k&Z?-~Fz$iY%62t>oW zHUMUF^z>~7%fmx9yxPkX?9~?NAOU$z4m6jjcx_v`W;-lG= zq!g`SWjyImhVtjP5WM3vBa^Q^IvipZZ+rDn9NT8Kt!i)gUNFuh@X{O@C)7z?-B@jM zbInqfyYA){a>liX78j7w*gx<1&+ZB}rRjgsCS8KjqbI>*MR?= zIpE-EQG$oH?k_3VZ=S3j7t#|B%m1%)t7A|3#6;tBySLD+zKS$^3TYcWw+Iv@PvPV? zdUnn(*W4as8?g3P?(L|!JKQho{QKjP?nA4=i4vsIyGa^N9BWj__*hk@gM1kns}!so z(t&PZZ<~MP<}N+8lNY0Vp3OW?r-E!p4{_GlJzD-)Q{Hak5pSIQ;p@u~;vCITK7 z&Vpf)sr@>}yQ0MRpaFv8vpImH%b1ZWuNV1;2jDLX$tcIR2rJEoq+VCGz3=UH7dfoN zIUj=O1NOtCK-xgAyvZz=q^m!X2-0i4D+0Jpg^NjWN1qOaD?P44LnmJMNrws}#POrg zmm3f?l&)C9I}2mv{!1=VW4eVPu=r{X*KB3qj>Sl@N?9)g#1WR1P7c~A#u zA_eUf4iBd8X%F1TCwRIM$+_Y%!p#t4N-BpJUmS8rne{41o-JFwjZ+J^b zU^ZoU{IcX39{MMjcjXw^<9>a=X1%44y@^rO%_<{m+WvXvKUO&Gk6BdR4)t8|UaX&3 zICGRoo{{r26Y2V#@wR8MBJf^d;2}|SO7gWOZ9A0c8YtD&@==;yoilyi!6Ek$?Vnx5 zclMQTcP_w-OI#+zeLxE-{()&PA1Vae_QDGwl`zwS)V6eVPJ)k&+sTuQR4S74@9eNH zEUnt<#X;OLaL#`p6UsOJnt!Df9G>SLX?>^$$yZPwKe!mz=S5jp%e>O-uH#i}cg6lu zg@%G={%i8yc57D)f?}(e?X4028JWQ=3!>i~UiUV{qcVqP$%(klFj`_{JL)0BVAks? zue$od_{-oomnC+(G2UEH9Kp=Uv1Te3>OI97sC8QqZ@fC_qo_Ght>*C?Z@KNq5_2In z&|0Ix`xja?clyd+6CIa%cVvKPo8!b(j;1_I%sXZqpo9I2Sm1OLNeUU!3WC#|pVrYy z@*$kuo<(Js9v%-iVJtqn(r2}y#X9yo+4yx8=51SRMO%V$lI`WpfA5xWkok&lb}vR@ zZ(EY>`#A*;28p3A=kl}H-0rx7FV7GpXb#$_9W{sz<=eaTsyTPZA2Dt4J{M>`k4?_Z zux;Mx*==W@d~L7p&z4^8gng9_&KoQWw0bt`xaQvKa%R--9Ze0;rSmm(k|;qz|?{+E=UQ93Hx+CUI$TQA}1wTeFz{&{%+5n-o!PLz!P& zm`30lX`Jg<)3~A2{5$6G8LYeSB{P24-G*yy*Yr7{K(bs4q{?f+h6dvjF{9(v1c{K2 zVjdb`l9pk3vo!uv{Y>s%cGslXt z&>)7wh}EMj3JXL02R7+40iVCwIaqDdA9ykJYyjmt-Yhi4J1n66YHOH5LVjs}l%d(C z3orD3w!TT$)TG{fLwzSOD1EK5AUVG;P)G@IY>4i7K-Z6PG(Pp~eXn}krPf_7lxri( zH#9o&d4*1iwX4haT-J{%w2o~u$NlX3=677-vq>eSq3RdF6DYtGi724fVW#-0%yt4( zKpn2{)igOiYA3wE?y^VZY?%$yvofTD7&-Z|X`;Jc9gfc!zCspZUvP z{X8p+icR$>j&P-T_CQELa%0^3!8!WpUe}1uf?|=9)DCOyllqK8Z%ROWSL7e3d(nQm)mubj(dX`MT;{PVO^h9ovnMc~5QQB38AL2MpfR zH5T+4{ERE-GwdIh^IdOAR`#CO;oG_}!$&lNLa|??J-I$7x>^8RiV6lfxKiSo(?kc%*%7@#oU`F5NOy>3+hn^iI-Kn(M$K^xF&2Diy-}wY zSBSi$xQB%KiRY9rlMg9$5DmE`gn(JNIcU9I9Kny{SH1Hr_kPQs-D3MTG&6ZrTA%R1 z9JR=1=+4)$z&X+mTg%YTvdy#|_o+)Lp-6Q z$-Yi)inv*nwve+L_E5e#COQE+p*|}C)4*%}uuUbw&L3`d)9dhA|G?ptUV$6L9vsiU zO$0pAmftJNy+zB6dxwxAjaLOBP>@@~?fzpXm;XJd6EMgfz#yO~aT{29g&n{-fI*0} z-PV1>AQRF5tKTOZCOtFm0_@0x)e$V%4asZ=A1zVq*fK#u|5XUzw)=*pi>|FCyIP=v z5GZ&F{vs$vmcPyM6a;QkhQzkiRM zH}1gXyaKPez}^5T&-9DdrxF*t{rK;Z^ZMIc%*;3t3(8GR27(H<4pLg#DjaQzH2ys% zyqsji4En1R`E5IN(fQ;MFE_?nMru)_@qv5Ic8-sHi;s-gv;eM!Yxg@%Rax|K=pRVv zx8UF_-?$ZX*4wV{bC3d&3e2@A01_VN7(}9d75n}x<2(jnLU3IKfGG`=bAU4V?~!w7 z@vUk=PN0Lpwn%W+W*p?;g3f;rV$ z;|NYYRp`)A1?Oqu4VFld3dxNc{G6aE`u9}O`MXJBBU&W5;vZ~#1u~70l4@%Y@;dFS zywv|#Oq%R^9*K*Oz$?sJV6QW{z^PMXG6uRJCj+<(Wb%z4KPVWVLc~dW&r^~N>=#HC zX|?PhzxZE&`G!i{t8qF*+9(d8$3kLta*Atr3Gcpu%PPQrNkO0#baT1&WTe%dZ!n)i zk9(xX^HXfux{m`ONw4s6xoo|VO)eb=YcbgUNXIKTL_gawtL0ADbNJ#ATamf(tSxB! z=z-wgOYC^V@n7rhBRyH0&XRt=^3~Zy2GkDCUP*%)Pz9Q4%tm+yU)L<47COE^Y zyNtWh+Lz+EkC>k87_Zd^ht&03__M5zolrT3PMsR@d1Ra>h1%1MNlI{@&Zw^3#)f;Vl|{ zfinud7X`4xFi$0J_hYl7MY2$141i#;OC?kgwuawgwCHtLMeuEp(md8!ilx3>Uh4hX-08Z1CL@>J{zsu)BvhvLxqq#_-e5J)&5r zt5PMUl(6ZMEs6$OpN9=W5&P02K<1@aEV7cmqHgzlZp6zd_2!36{dGGBTdAm^Z&oFU|6y`z!8U!j>!X z=K~KWQy!ll?2co+)lcl&)_KxPx1@+t7#FFWV&iGFyn0<{nl-Mr<6d@ybBua8HqW-< zR90{W-r15-c+l(ItyCM@vPK0R$LJLvcG|-CZf=fVS8f5=rqxJ`P=i#u#!O9mn$3xj zM$Po$Rh;yjCP{3$IlVG@sr!-(0A8X%MN?XR>H_ z8eujie#N+ePMocYVVGA~(mkLPVLc3VBKRF7f8%@nwbM%{1WnnQZKn9)w5QNH(Av$S z;pU)KxbYHgyPe$@_|!t5=*!lsZRD&wZEa2Fyvm$8_D2~9?@l?ny^6z0y_ah!jHqbw zT+zMsR_qRwBD%(^j}~2*`if-J5W6qo(EgX zR_|MuuKvVw%8|YqPE9$AA?Uakd}nfLE#+4A;`oQWuq7F{b&tpFA~4#me@Q-Pd5_T5 z?8ZyODXQ`2+p=fo_gT+RuDf@3Ait&KLeIK{1(~fS`|3e%VY-@6iPiF_Z>r^wMoye3 z@ckQE%s&guB5*%Y(&is}5Gb&gVlp&m9S0CStVL~uf%6jc(w1gPMc)t z&_uVk!Co!Kp!nFhyB8?`0@TP!PcBja>5LTl<#gPHfiKc!9gWM<7ZfrAwjag*g#B`R zlFy0p%IWnX`|{f1t=?oF0XGczMnP`=f74m;4SVA-@7g55zPdCq9t^wQcB}tW0I?bP zVp!lfDE;&=dkpYQe+nRW!=?}@FR3iUcaxYx)K`fA&C`iaLPm*&32+jEKyfzMuhR}I zLt5eHe`8W-OzLcNUADE=CDwqee(p|tr2UpopF{S>wWIWjX_LQ((?osbaxWB}V{DF8dT7`~u!_mo?%mP7)qS)eR5zZzcHgyESUKF92XsgH@nZ zSss_^-Cu_N+lYLd!2_g<0HyAl2QCX6^hy+6xEiqtj zK)VXIBy)I?5Ht(?{HN2IwYR+uo2wYxlzY(s#gknBmY!c21Wn2g6wUe!wsY8A=( zK~em~*8BLzoAI4OC2D`idY8vp%Hm(kz&9yb6lGeSyAt&ZPDb-`r` z_X#dJ2E|Jd*I$S2?;hdDzH9I?KEiKp@C5Ji$)4~L%sgDqJEtt)zgdv=TzYGVBic;# zdRWG$5K<(2ydE~~9D^vBbX5?g?HbKa4@2sT7l)Gm8tw=b-0mB-QbR7?i|%`dk^wJU zp5c+Cu+Zd;p&Sji$a?SB;qU^YTubf0?|%6uw;=tRNNEJ@VNC5*ayAL=a5T{C={%zF z_H6@4v2_fJ8-pU2d_a^bDwO0(fnBcPIxmr>0aKT7SP;t}UpZy@SX(LsKFNHrBc{WB>9atA@Al_+5cE#&hn8!(6Z)UpN{aT=Xj38wVav46!)3Z zhF#vbk;V<3WE&HRV69#q1hyzWDSF-m$r|+#wfl2NKKuef7|a5DrbdV)PJ;Ihf~4Ix zaN#v%X?^@cEWnJo@AQTj;B)4SDup8OIU`Xx6Bhj0r}M#dK#0_Zh6CVJ8f7=r68?0z zCbYCx*&PAr--M`&KMGd`j%XbLKF~~i{}dTeX9}yBW{vU*x#6KOV6no~P3?D~5E=^{ zSikhn^k*lZX;ql+ljl3r`$4C0qqFn7RG+4W(m=ID>;>D8ssSFGzu>_$0eOg3w4SmM zLYVUp8^!AMwE&yHa3Ew~kwCL=3#u54c|L0-Q$fp6e&`O^lIVumqtTVUYC)h2p&1P zjdMweIqFcUgkO@yE(aL=_2U~a)A1AIV8LDlTJg#?pr^@E4M}+RSl}>3F5dZk9NO#w zdH<0n*J^YS1UV`ttS;-1B6ubn?Ani5$T^FCG90m{f>6BvFHYnq{J%&4-C*5k^`Q^F zBuHv=S>D!{qt}KeB+1b6%E7*h+)HnPIKq8R4J{oJrSPepEc)9z2tt9~8BOlgqMdz& zS;`J!5AF{c^x0KOZku=tsg>_O0so7?C($Rh`#mzBo=3_20Lr}pjY zjO03lH}OdToGh14De0$5F5b^k8Uo)pb2blcM}d>Setct;H!kHJQ0hB%A3JRx3T&x# z1Y~#IoPkC8Sv)bvr@P*UPs2Oorb%iC06@e-c77JIhA+KGFbF96w~ud(TOu;)alh+< zAgL+az}`yrz(zs zF>Sr($_bBm|7|*f2PS12WZx;^*UI<-Y_5R(cl`B1p28bk^u!7Q=+fW!96#asJ*emR zfskj5 zc~ta`su z`)&v&>@T}^i~Tfh>91;8@h#zMeVO>{g_{NYgntJ)Sq^3uX>v`owEq-oo6LDb^KRY? z6%aK3IyqC?26Mp1P4n$rhF`r8DP8mWrbC)-QgvZB`H^QE*;H3EB64R&fLiUw=(e0k zDnqXh&EN`N+FE*qUBQCn*QKAZGAltU07SXdc0)2#z}FR@e#3*1nhUhpL(mGe!6LSS zD|?|Iwqq=W-Sg%i)M>7qoX}|AV-@hy+;TwZ3t**C{^}qP`3m=Tf`*hkQESsi);mi5 zV9f+?#au7;dKhtoAv?>Wc5h2jd2>ZcLrjdThQS?y&Ki}o&u$I-7-@3e2JFMac>U#L zMwgqq`F__`?Z+o|J-7SRMFLDw?*-x{culj+8vT0Icyxl#BzN2*vv!Nk9q`hmYJqC4 zr~pkugOZ)~f?4;`5TASz6Y-P3HcrCf0|H)*FG+x*1X3+7h$ysV22a1 z9ymEtn7wX!=4W~Oq)%`W*+!fVU4H`yxG6d?hIx&0rd2Zidw6`-6rflXnw*RUo^6*r z0xHKtt-lFvO~4zyMI^A(#gU*t|#?lAK%_8vXWL>geLs(f0J4 z*zxZE{x2z$P$oI_OzUKB*%OScAx`|`OgmNM@cR1mR^&( zVx~>+=c+)ku08?mFv-o7oJg20nc$$x-(rx%*0ugK=-{bN{fx|HW!!|4Zf!WE3?3QY zj2edz_S(4!{r>@fJMeaUt*WZQQnA5;3Vr3ZJBcI}S^ zI)2fbE$u~KU?x8uD8Mma4%0f?Z{S?)-}2?k_dex=8P~$rwN1)~ro{aa*whoqK_b`? z6;Y-{FT);@hcFviiiVYaR+ZfklTLi=`Yd{r7kCKxtJWd z=m63r)7@9u@Jj(cN(J+x5|<>KKY03ps_@dv5Si%Z&5&62G%J1&*)P}KA}lRy$r_Uh z?+zb9B-ptPCfz$MSQ!3okf-sD5qMc1>cX|tCn35cbk6v`rmn7xJKHYvup4r`awahe=wHQ(2C4aFq7vBBNfn01=3%PdZ|SjHz(^3@WD z>56S_cb&cr={ujY+uPn2-{FWSR4`=JkZbqH(4NBk!~icD{-bt(&Lq;+`e#1~$!N1D z=1oVF)9uWq-8WS+n=Sks&S~-wxO7K1-G7N&lSEHgm%gR_hXDQ8h4RUPHKATqmzQA{ zUFtgfL(Z7&t5kC`C+;K14f`y~X-&#bF3mYxt;SvzHz&vT&5}X;X}f#nh)3Uq_*@CJ z-q;p+$4Be3^)XxWeNHKRZf*9wc^)R(a`p>%e`loNvWDx~-5cB$rc+a+%Bg*)My@@%m!m}8~u>rSo<>vNU*soNLtk6(B zOK+N?*?M?QYP*k~p<;7{=g{@sg_e|yFK=&zGQ^JPaoD@Wo?ms%=$bT-NV~z`!U2^NDj1K-sT^3r>*2b(R`;z z)ySZZPDew=y~zGd2b*9=9(761i;T(xy?HubKZc+K7;U-KLIY-~Cnm(;oMLN^G|&v+ zJ(`%B2cP^qeA3Ijpje59Y1vL z%-|<0W1+N`ickt~)p739mECh0CHWgQS!H|PUv>Ha*n8`^sG9F_{O(eMl!SzoN_QwN zEMXwsAt6$tfTV(=te~JsNp}cRA}I)hiXbkqbO@M)lnDY#Ki|2a@8=N~gV*PIe*gUD zwc_1-XYZLa=ggTi=bSmic#c?f>SI&iv-Tj_rs6j;_iS!OaU?tT@~3yIxz@)Q~MCl1q%At@rp!n|4S>e=S;xjG(wKJWL26~+2u=GKnkoAJ%wVdv4{ zUxMD*P@(WR&B32F6FmDFa~3?zflX|1s4$p2m??(d`}}h1`Ug0p19LQF;JZ7MAz=XO z9}jR{{)|7)FR4Lr=pLNLZVP^olIQcE@CUA!|Hi$aO`aA$y1=A{4^s9NvV$`R-6evH zmj62)YCbpS>x?iy8VNe!IedI3(SO)hfBG?n@b*t4UH>Wzm7;O&iXo|0=YnQ#Yejf z;bK7zG?pZQskHm=H~!g`1YgKskPUN)&-yOVXIah$xvzkO_u+B)m`dCYPz!fAH*(wl zeMR|a&_Qh?ong>{qjQGqrU}=^^w-^R1Au+|Sr~$Cg9a!#ybz;3Xcha%kXL_TF=u%b zYoZpgRvQf(jym`-7YXnN$rtVblQ#R9CN2&{n;Kh|Wyx8k3~6nu1*C!Qa-N38-I62g zYz-?-$U)bQoDhHE0n$Zak%6}80P71VumJ`X*cJm9MKt_( z=b@EGuGntCSJ;dLYAJ@nbrFOLZaItLR}_LS5lhs97jj`(e_ALS==}yz>mAB1#(L-GW5MbOWdHvFrcX$k`bqEPC~A$)$o8l3 z{;t2yeXt5L3^-K*wyA*j{}ISUG_;amAQd0j?G$F8v&fDie^iM!zg}PO{RK2l0W^j1 z(J+=k87KPBK!V8aZ*25mfkc4A<4u`!ASCKP!xC({S`i&o&_*DMC3?%69rg_lyIPp$RlNV0HpoK!EB5q6+DPi-uazYpg#gqJsxfswT!7u}A+LI0T8^Y&5#n z+<V>x2M$ zv*4L$u3Z%MQTj1&h(ugXYHR4sxqF2`E5kDx0AdE{=XiBEv;v~@*y^&)0*B5#6~u2? zBK>hY8vFg>+iV?!-KWxzkd{AQ-xcg`q8tign?4elk_xywj z5Kp(gEtB#LNZy+*uYDYoMK#+Kxr~|KR}74-PST|mN;Z|d(xlD2ZLv%u^PzsTAO-Ki zG5Kvvq3L6bS>tk={S;4Antgt|<#-Q@oH;eeeoR;~GG!@Nx2^C53QUb;Cj(%`o(ScM zLss>Fs_@)ijn7hPb&|0r*~_?7WbFMt`-N5x(d6nOcfZu(iB^7(6H5m{`iy~BMg!!5 zffw#M$>>;o^g0(gO|c`^YlaR%Mqf^q2tIGIqP~fL1 zD58LY1fYPA{wWHC(`N@l%;kqei;ac{4UFcW2lwCWiRe`E?8v8|vf%kBDCJq_(tXQv zUsbIT@f4(zw6Wlrt7m0CniCWvUUhu2zQ=O{6V0G87K6^0 z6zjD2e0KZF)EWNsb+z*JpD_jnW9Onm%wKvEBjXX@|b@F~xf zIAWe*&92H`wMpgojA1JRec%IFIcixf3pT9NoK~qpEatz3wW~2!m-~X-OswAwYsRua z_Z+n3uUyO8#31$t!=O76exY`j^=0@EAJ-AHGM~-y3;&7BpIrYGZxFYKj|X7G#!VP+ z9I)rjk(-D8kfUE_?GmQnfzb2D7g&&i<=cu7*l+QQb3N!enPqeQ5;!bMNyDt@@;-*@j6gPEgo#ZtM91%bVmbV8=)mgv_m^K>&C3%v+v~Kgvouz>`rhi@OUaG^0iDjL zFM;F2wk|yGbHSjZ*Jerl1%QnouQgXxH%ET)HfB9zKn10NlRCgEjuKiSduQsJaFMW$ zU;3*j>jt@PUiMP6#h1}K(9e)M2-r>q0b>>qII+RfTYeO8bH94`#?RMUhFS=n8Xvyy z%0MWBjS$H!y1y$ihVNP<9Q5v3bBwrGkKG79ONf>CP~#!CSR?b^5Uy=9`3_$Z)&Eu+550xFzLs#Q>t(^uxB+ct!#JtN#j-@tsCrO zrwv$^Gt$`bucP3RhL47XUQ^pVNy1;_Uqj<3UlJYdYCowfvuyE+N#5=Rwod$X<8o)K z>OS%(e?inS4xcK#s~ZioEU>Cv#zIuaZ^@+$RT>SnJe*ms4|!KMp0R!yG}M)yeYQ6E zHaTmfJx8F2Y?YKvt^vvv)UnV~Q5(S{Hta45+ZyRgy^hGRpr^ z4oqh_CrBzV1`*8RW-hUtQ_QMD^v19F^bX1ycZ&9SJYdjGp?)(GznqmO`C9U`Uy)5l z=k1t)yblhii2GjIB}YxMgkgA|S1~Qw*<$eZ;)X;$rk&MI4(?Y~P5R z67jsIf5utys^#^Q&-uEUH#=aOc)NCzYc-znmuRiZjCKz)yLLf!_$H>a&ug|IZ}`OA zKJ_Pab9QcmS9Hc(qLwsOZj_qslrngzFzzpr_*9F&jU;2=9;M!EV^w%U`8o<1GBHHQf$d!3{A_~Yp0Qp3nh!`a?iL@jpEe8<-Xj9W%^`xX zxW~=!^d-(ZYp0(<+q}Fovi&f~NDx6?#D+xuH!zu%5o`z@KSPk25*Ep?af@V}IQ-7d zwK14i&Faan@Nk$8w%%#9DSKqX`bL?$m$)3wi$g zEF=)19R}sBgj^TF^ZLwxz(QY`l>tcnRdNzTbUlEa|7Nm3K+Y0CPFikYH-yOv(#lxK zabkm{N&h{S>40rxt7VtsP1w{7Hr^oEs1V*jA_$L({%aCJiXnk--!*M2G%(7+J4irU z6uQ=bxofY-72oMC1+H%hj{Cg|HZO2Y0uHK8#r_yF9R`W>3ET=Xe~fc5{OV@S?Smmk z9P8ZRFv9BBw7~F$BQkq|p~H6;totn6Y<^t#)e|ph1Yk)Icg7B1X5;=9FdWbEBV_j*tBO|?EU|sucW>Nv_~qO!zF0`7m@p0u+~@F zAmS1TfYNUQ03ho<3GjW} z!SBIZUyGS5hECiHyVmlvTg>KU(Fs`ofWsM{Lx^4I`-}Tx=-nB9GM(9d z$cMtFE@{h}VaE)How!v!gy$i>EGdXab_ZGJ_8dJ(t%vr}O;1m__2wU-e(MtV_9a*b zIfBUm7z*It0L@wHjgquAK@|V%jWdh)hk_?dWKZmRCC{@Ivhd_>0MbV0o`s~tt8061 zZWZ5mKF)K-B`5cj@UY@4~N=dpH4QF))`o$f2W;)*ZFzx!p>n3`3$rI)X#gDCEdzR zHQl)F`smFhXP3AZk1_Xq?ymW|y@{u;e=)PsI{Qv^5|96U4MvF66;qs^q%m%W-~Wf< zH+UJJ2f)_kyPWymMI#XIgiFG=Zem=Gx!&DTV(in|L(Bgx!PE~m-UtcE&wY8kTZjL z3lu&x@x@_XI`DrASg@`5&od$XlnvobtcQR-t`{5!&UW8>U&40xo&S}HJpx25&@5qt z!pdH}v$N;K*JWV;_a@i7U~&b>!|5tmjGp|74gfY6T%O!&?m)4l# z6*~G{+9ihzfDvO;WrzNg!SbnOskNKkU0ixo?sU4xhLn)KmIEdi3qG5t$`fzwAKT^SK_J|z!n9dqA0^S1MvZ+sYb`e(3 zqJJ~3AQ}^&zAIWwbw^NYksHSv*HC6V7zE&m24LSjv>m}lmjZ|pa(N9gP*DBNThTJ2 zKF*tIgS=EO)ayX5?M`?e0LP&raQDMzz{0-)2H~|r)4IV*?Con>fafpa8389g=x`w+Fy@#^)+p6zxXqg?>ulFx5#zBk z&MyJ~%RZRK1j7Rkv*~H7YfU(tf(QR-tsNQYKlx<>iU3QHU_Iat0Eqx-9>;|yQTv_D z@(Ui2DZl*)3c_ax%WQ}e9PePS*>&>Zfc6&~UGh{ay0L~#0Tens6$bW;f{V4!0qa2+ z*s24v1A?Xi^d0GOq5sjLd|I)$q;*kB4D)rbTjf;kCE->#;REc?G~akLcgC zD*xWgH$04$=(O{~FPC7IPqi4F}idCADjN?l``7ahnGun?Tpah;*99Vvg#)c9-kaPwu_PGHs| zEUE?GLxI^ zIa%r*k>YT0+Y?NQ@5GyrFJ@;vhVs1l=6s6J?oFX1wQT4$?vDv7!fM+r=vKPbXdI}O zo;m9}U~=0~$ZHs**K~{LB<=JOIYDMjM2?^|du8`wH$tf|az<{}jq~Np)BTBFs9&SH z)^Q398RiZqp0FhW2)#oH;AF(qJQLwkTIXbhICi3-%W&`rG2#4^FC`J2&ImY}kqV?( z6Go$g*vifpOV2hpj z1ru#VvyJZKk`f8qQ`qpqfLIZNP?*{Bzf4Qusr(9P3A}}?w8TgMM<5?T@P=YQOTa$= z5y;i;L4cM(Abk3lY3U+nU|6|55iB?maJpF}3JU-DAAt;mRx;dzkQLwm2qZolq#p-Z z0$L(B068xp12(w3=%JuZl<*rBaR$2F>+tuEaiQp`$eZImzRXdyDn=Ye4Qo@JVt!LD_qdwMUffN0Z3SiekH4+6?Rtco9KTVJ<9{W{L z&Z;(ImU~l;fLL!!pOckpC7n3DcIE-W90g?plF%0XizYopz(b8xI6aGN=2?&ql@qk6 zjhRJnt_g_w&P-QKj7ZDci3kp1`EX4Gq9jK8mp*!qL;*?iV0-%&X{I$35%3)I=i8z- z2Oc6Qa9-p)qV`~o5%wGYNCcY}*nGI+G==eJ8uMK<^NxF$&t)+UY!*DlSQ2ZrY~Q*x z4KOD@-<}A%UlH)Y>1rh40dIkHa(9KbrpUNB6f7&fS>yq&s zphr+EhhanbsrMEIvT%&XRenUOHFRHZbDc|%d^tEOpVMq-9N*Uv^(Dz((E1JIw9pP+ zW8DNBw?{qK7s>bM9sjdN{86{eyz&@N#jpm3_D-7z0SP<2zX5 z!5$3QBkzQQA|bO2+dDTFD@1TK?g+VVwpF&x=dV1^wA*f3Vz`*MdwsAGGZt&BI^~Rg za@s32^8W6&91p+NlYESS&`3~qL;fp4^-i~ezFax*8T2Lc-9%a9#v@R=Lz>q@*fmLs zSw<*UO4FeT+k_20sj8ANkfm=PgB%??EPda5u>93EwHOBnOhO;(&zgzbsR?GtJ!GNk z$o*hx3{F_C2C-cP-2;x28;?}8$&*YThD@eh%sq-2ll&`s_N_!`^GzhBv9s;Xo^PeR zD0p7XzHT*Tk2m|X25u}!QytD8Ux|DLq69w`i4rW>YQO#r=@LFz*d5+Q4YExEGmVGm5rhA39w|}i+tUV2 z)GNmr10%xLTQ$!LN0rxDPQGVl{8OyI*WV!eMy)%Zydm=<8@#i!2W;iXhs?~kcW*q_ zzrXGe|KC`JXgjUG2RH>_QF9!e6oh;`QI@yy5dK53->U=80|6`zYh5$cdu{!S?Tf)$ z6h4Gp2SPz~hL=k%*G*s=(b#M8F2mp7_Q(H^o0iYrV$oa^atBKWfe3YAV*r_IK&Cx3 zg6*ESz^!%j)n|7DBdy-y5UoFkyZQq>$ru)+Hu`nmL4f_`V*6BBaRS(*0C+l0T-d9J zV0{N#K|;_?7G~BM=4~OK9&63qSK~_*`}VX0BLvL(Hz0g8)%ZkN)jHzW3%w|8+pQ3X z-W+^_7?(bs(6~_Cy$`P*LGSkg>j2#Q1?zjCB0YU-sBN+_H*RzAi3Jw?n@wjzw9?l) zh&fL*Q&@MRkl-j*kckroENi#29zC1DD6fe+WJ5kHH;T3|N*7(+c?jZxAR06>3^XPK z{DZ%NOz#lE&O;>E?ZFqy42X7D2SkE`2*B-?q67Y}`~=PhZ%y{G`F^mK*sV+yj>X)b zecu?;uAmQ?<7qkC`#*SE-B{;G;t;?0l71O%68-Z+QOl@|H8O(MIRn0R2cQU?m{b`} z$*bV*DPvAQ7QSXR_vbKIe}Th62zXHGI}e^+ii5xzDrPL?J^s?{Yu2xug$cA=++?=~ z90w}|@Ubmh({Yl0^Dx(%=fPU(hC}>uFw)>S!+HRcSkQ;)WGyq*zvq+$;^it3FCg{< z&SwgN<5zq~Cr($1v~&EpK&l5vfl-Z?cSi`QqnKHpM~P!0L}6xCDg+_bJ|Crl2!@=S zB41#QkLV0lvK1KI1;Y^Uh7;4A=<;vHN8zxUw2Q0nSq9Xr=6qm!eJfrkFDf1wKDU~*~xQA1DD$+1{q!i!_bD|OuSjouY&>&hHxKD2b8SBsi| z-rUPdX(+~eIAHrwQk;#w0I-;4+L{ESe@%ZJB2^h?XCw*%A9fiA3?mhbWCf(|DnA+| zfGp?IdU592D6!8T4?&|E*QVCok9!9~h0aN-GQG#jkza_pQ^1O}YF>=EGgTYz(t773 zQ%Z=LP4wQxd(WocW5tv1Ml&^wmOIG@pR~8PkD1ZAvsIO1H8Rsv&MJMMbD785Z_XBkaC=9U01qCvUaWwZk z;u2B#bWu;@V%h{XH_rbsm6*2$p^5ZenrE~H&a+Shf?fc zNQp5OnBL?Oc#?QiY{~w)mrGZKfQt{Qk%1diD`!IK`35KB#wn3i^v+rEOJbQ83Iz8- z1-56BYW-Y>U+ETz*4ma_nt0Qqe-GnW5?Iuf3jM0AbLE3kb8ThR;D>>I;9;<3$<1>I zP9Z35$1jm7Pg?$!sZy6;UJ6q_++}6sIJkJx!(dS;4_4K28+h>w! zU0Sl3tx`_g`TJMd`=9Oj3sU~XR6hHVMWYf+ksd74O8Kzs{o?HB0G5H|;v1jJ`*+Kq;pa{CpMK=sC$8+yuNP-8a;9)hBu%XAAN{M(9Gvwo@pdp08B|L6r8d;pqL#N3JnD~ z^_Gp$_~rfecz%=yeR~xw7lES?oFGlZPRIEW=QQ%mcW+c-opetqAFJd{U_+vS#goDs zHoTg~FaOKRiqlX!&>R5BFyK`O`Tf|T{|Y8>WnBW^?Gvapw*L$!P(In$4?4Zhql@K0=*}5^N)a zlSP3XLIBk=OB}l8Liuvy#BvXy`6kM;DvNq;4As{sE`Pk zcMG;S)=(+Wh8CnG(VKt{cG!p3VVW0lEg&mHt{2;*0s26D*_VosMsE$ZcrKi^e~mGV zjn$ATfNRU9fI~=9A3E7}@vZP^+HKtltk%Gg=6McvXbZH`_Zop~^?G@8Z;RLbn}ZGE zOfHhTiUk(CnWyDPa20`<3@ERON zWg)cJ^3b;j%OlFQ=>JthaJpB2`$qB1S`DTkKK)0*>b0`XRm>2k2D=tuaCju3!Jyv` zkAy%0YWDckMxDf}eI|?zp>ilu)BDcfUe8+iF;PlVNltzczM&XasrW%V@8)EP_ub@G zoeRNPeX<&U28xf{F3akbtCc?y(nnF>zBRBC^@B_N12UnB>*#dg#8Afj_+#*vJJ2pR z2p?@~vfSq{&3#8L+`i9BjYG)h_(@LLIjF&PrW(8jF79Pg^KNy`!>1(32%L*-#;V*# z9SRHh$D3U13;2fvP-;1u;tu!B97JDE3EH=kHw?8*#?5s%)V9+PKQCzH|C9$oRS6NW|-r=k->SMq62msFB5=*(#ivL|I zgbco4r%>(X)BOqdwx+Cbo;z)HWobg6{YUHf<(duQ>J*u&dvY-l>V+_?&!s^0QZa z1IAPJNFN9QWgNvk_q5ZsUYab$HZu&2r?3jenMq)y~RmS8K$BXR_%e5V5S!=$u6JgwlzPB1q(wta~mufORI9> zNL@pXcj9?Fe?2)}TWMXU@pHj@BgYa&hnWgL21&`s)yz#Ac&S}_P~kG=IgEYuzLm}@ zuz2v@qLh78VNP+>WlUk=n`N%JszmuF*760BY8U}fHs(-9%nTs5rXGnx5Y!cXDA&D#Rc_!+ir!PB^Toiw*s3**G)Bl2ehVlvN66I%Fvgb8 ztGuJ~jb`Mey2MSwCl7d@5oC&WAK{-U7Ad1?d>-d4#6@R6kh1TElS<4MJIuHo(+fll zg{f6TQ=erw{b;Ntmz?~8!r@6wj9*_e3nr?niY3*>&27Y}`!36sID=^S5r)~h=0|f1 z3!x?7iMMG&-gLNk6pT@j-tYZIHV7Vs8xxZ7y?sD6VC)A6?i#sCFm!i26b%dOWe zo;zplkG>kNF2iqs>E`>}WY(Azwr`Sn+_y{8Xcm6m59M2n-w9c0Xpmw9RR?XBcBNpM zpP4=~HRz@dMkAJ*t_BeHuvEEB&Cxd0cIfgwXIkudGceBNn(uHcZ|sE|5LEr<8vCA# zeQuMoYVYJ75U-ub8uyJ8GCgqk?EI;Cgf7L2@`l>@MPTu&l9JSvlPO>-wr#1p9L-D? zVv%u^f0~YTu9Dv?o!>_i`U&s8v2~I*z-F%QqGxFW$H)Afcgd%Db>mP!APaxFF!UY+ zA?{&&IwSl<2k&saC3RgUk~i*{;ybgW)vM8D;E)ltA|xGM#+;b$=s$TJ6xivdM(ciB zA|hYHq__N)cq_|v?Y`6HulZ%%$Tjy(4ZbyXy$80oj@G?2`aCh>ec|flXUkjuu0wer zIaVf)X(e(s`wG?OILkI&ISp*c7mUE87^dL>cgrZ1@UZfZGn7zkEZjC6vbS(_x0p*u znVB$zk6w|z_|PFiW*^6zr0pi0Dtq1+Wm3fy-`N|%;*GYhINJ}*noH8${diMo@VXz% z5~eQ%?6HoyQ}kv~ZT2huSfcCVFd};2`2ddn&hHvh!mARE+93Ig>%#rdhDt@=JUuILM zmyx53Z;_;D z=#H*Y?gBuFdq`CAI2z0jK)Wvl?fH3!!)*{~I8QY+vRpK(DMY*9nH#?Kz5K6b;KrsV zs}Wm4c)$at0}fpVo1h`&K+l&KBJKNcSVw4J87+Cv@hKULjMfvX#^is4N@p(779>@1 zm*0y*P~Zgmdt2cD|4-Uu4htY83Ty~&o*8dbl8%4f=akpI7=$-Tu2OH zVFcSM3xhqmkATc@yKxQmqkcoZv*8gq$Q5Ui_qs0k?U7t-#|6;A-wV+v`EQr_DNQwD z;2JS0)IT)3Mwhg2@i!T|33N#$@bUm%5=>bLxHFi>Zmj7Hyf1cjOU2dTMYzgOmhY4+ z^?^D4f~nM+9bP7GY0>55=LBQ%!LkU@;wS{R%lHA+0JA^(;*Y8U>lxP9DJ1BXcm44n zzX_MA#l|&O;m*XS`xw8lS2Q;a94i7 z8X6ii+q7n3cq_zBv<}DIG)eCAIUWsLeEhVpMdo z+|@_w@S46ib&CUipCA|uAyG8IM+SrhJMuLUT2k#c$v+A$2s(V;Xfv=`MQhfc4!?@H z7#My?6fGOb1b{>Vn!|`=4s2hG_-0_P{N_iuZ-G=j*3`WjXrPCX8FfYLw?f`*CzBDm0~y<5(iIF$P8eAU`7Lw9QWY?p1xJ`#{mvOY?}hBwbrBV)pEe3 zSK}4#;sg=>Z4o_mu&J@-!&FGF5z`wRhtAVx{GjWB^TlsP zCxPkhcLTeI-W1&_tYg@q%>jqx!8wr$JI{b*;> zeBDs{^(b!W6d!G>SDz5IFKPDwqtX)B%K8r6iI|9mA0QS-ECNE;nBATdNiiJR^D;pd z5CEwK(u{<$gL$nIXE#uDV5`AQnqtZBwdSH7Iu|Ttr<74LS#!qmq%T{0c!{61l_v)>G>h9pqZ`rKAH{uO+ zm;guy&QQvqi;$Yf~^xC7yYQ$FBvV0*rI07&Y->gXo=DvySiFR^YZ z1tDh?FV3%w1Gv^V?odRpCgS}kdyvk7o4EAQJaRYDX!`~6X#7;IFvMSvA$g!E;p5}s z6XOveAOs#h9t05(5+U*MMG#v=x6|!apxj19O-nB>fnwX^OtVv1>!{=|Vll=`y*+ zQ~hakv+a)#C>X`a%pKb0dbK_UkDbD1-#)&J6K$XS<4Yz)wfn7a>^qvdg_H5UG`E8^ zH}}oMVX4bB*AAx}M|ydg5adDw#1f0DCRk0~Y9j+v_5ITt5u7)4^BS;e5ld%Jc3yn_ zm33I+l|MyF`9m_IcTQJke4RwvvSzp@3Lp3}gef_Ab(uqvnndl`WKE(rjMNlIlgsN^4&tfN zZjG@hUWd7>{13RvZblb2za*u@Axb>c@$Ys{TGgvWTu zp0tBsF-M8NMCgi% z5NnuU?{aRid+tTqRU&X-jcd-0F9{jPejzNMimk@_lMuJ%eidHdAnrK#_}G!t7L=dg zOe>LUKfZ1m84!-8cPAj-k>TWgsV?x;$y5X3gTquKs&Sll-6}D6Oy_ko$(T&lj0}#s znZ#l5Xg7vo?ou5UIojqC#&0yk>uRC8+Ocg$I<_7LN!a$6)*MZW$Uu4%gSYf23o3dEi8fJq5Za+SS`R6zG9rH~l zx%WwMeCO~>cCxYIm;_`^30{2H6Z5nuX4HzWw3YT2qSP#ky5dka+jC`?GksinjN&LEhQ}5^(XR7Xr&KZ zd~H(U=WN*`S`cPn>Uz@Ptm7ihol@FTfpa?MSJR1z-dvrdlnu^vQ&qo4Ztqvtp_5~O z!=HQC79l17zPwDnf_X8@>k*=%Y01Z}E!hkV+GBKEJ&&C5$_R{n8v;FaYUsI)VIRRK zv=TDEMB+ccx3f}3MO{Hn4Da3hN=>#a^^7UkudBvRmUU!O@pt4(`d$~;xy56^c}s_D zI>lpJ|9oK-ul0EF5zPZpnS=ox?2*q092Yv$Z}>GjkMDI?B~j_H?QEG>EkElU?_t07 z^lQ4Ehc5&K=Uz8fZ+m@~Ue0`QT8z5>#C5ipPS544CigL4 zPBN8&6X-Wwr;~PcG=$BH((KEnU_6q#0+-bhUxNM)rElCF|VWu;BuBwun)p1Gyv<@p{Y| zO<`~6If^~y4~<)spnRt#^Yb$V(oII7S7qK=0zRB+H{5Imvul$Shy$TL+~X$+oain6 zN$?u++eO=5J@1Or=$BqDRj0mA)n8~!#w=KvZS$_pmqDlh>hT#)ors_*s3I>|F+)n9 zo>Mb7f(vUn-Gyb$y_Y*asMdF9%DC>nPMx?Ii;58Ag-bnIO7}m?E8&MkJ2=zb;#HRR zTvBw-62Z57GG0PBJvW>M@q+E?T+HsJmP*ZkJ=^VvP8}{8rP=nJ8g`Qpdvyf zM-EU2c9ZDj9G+8?Juoc{1ZD6-%ccEH_h!&W-A~&%c85q^zcg5iipwn9VBJoL#EBxE`5)Ls>06N2z0>kyS9td zCWe_?9{94?TvV66E3}m{+q<;q^hwu|oG3reAl-De+P@${`nG+<1sVb8UR?0^&O}Df z%I|NC82aFUgly$Q$X=?4$|RrEBW(WKVOCpPWoKafaNL?^DsszYY7B5>m;J5lwPUY3_vbU5)47nBXv-io@7wl>ayGEs zmP}!ueRf6k98Cyv=ed6Vz^|oE?48*pfl1v(s!%We=jG zZq?aIR^3BqhG%BRhwML6{-Rwi&Hcy}{hQVZU2gg0*VCN$rI|mu%y08l@|AgK^5wJy z%#JNHe?b~YMB-i%b(U0CuSx{>y+I#n$ppb@A;e@n);Z>R#B(>F+3palu_n!2Kj(}- zeYD>7Mjb9Yl>|+xYda?*51onJfvVUd|2Na&IZSl3zX{{*%=Ka>!p9FJ@#v(Ow--^O z*+xj$W+n2_tK)eUt%?de@14)dnhK8)JC~$~uP*J0O$*%e@IJqA4v&atk`8x+(!}%D zkTY$sBo5r_l%nF$J#^%(u!MgY(+#}d64MNtbHlDr=1hwo81kQr!tNM$eRM0eN=66> z-T>^2e1EqWVcFAd*oF++P}i1rehc&cp0L5 z+IjWdGPADcL_BucPfA(PlZkb^5LDKb5PaNc-@`HWv+h8OP49UU#(4M9NBz7G+Z}_h z2WAtmKj30Nb+rL}*?%tkf!GZoX+6E_`G{lo@$Wioe;8#;Q^V+vKD7joYCMho9H`2z z!*blyIXOjf!)KJl#2udY+j4cAksXe(zpl`GP&3PQ>v4A4m^@8Ob^9N`8*&NcjWY6!2YA?E` z3LJOWAMg&zJd&kRAFP|XOD(RH`bg%X%IcQl5p7w1Dv#`v(2UA}%f;UT_?*b=*bH z5E)|zt_B|JOXBKd6hB)(Gkd~{AszpYLyR(t1xW4s(sZjcJ4*1ov9SC39-XS9Av)S>-I@x%M} z>fF}49XsgeMbr?{)oiFlr)a6{&k&Fu!+34sOU{QV((K*74y=j|4hOFAr{*p>l00um zm)8_#-Y<7O+;cf~jz3wqp)SD5B|hZc_ z&Cpqs%)J#k@rr$Eoo1Yvn!>bF;iK4PHF9$jm#w$l_WZLkV zBCT(pQp)*A>7bF3oOafYMswbVY_Ix$W+m%Ut=nUW4$t8Inj+Vn(&JN#G)dpFrCfIdtJi|G42|c z!JUxdn(0B!{YtwzEj5illiwmDF;6k2(~Q8MmV=XhBobp|Mt;oCe*1j_{r0GMt*4y~ z+U|na7WElbR}>J-NMhHs1G=iJM|M@DpW+)5II-h%POs%9NqTy1T5W;IPW?v97{53% zm3tPxp_IynZ}A`PbI!SW^LB)3heYXQbm+|bH(O1+S}wWoVFu;y|a?@ z)b7)U$h*QL`i1)4-G*J$>`5c2F?k_nI zDDqXCQkDDMkxnBvi?nqA``xEh({ybGLrjSpsiXG#D2WfI>s;M8Bd#!E!fCK=Iy{My zD@dopk3BC+Z7_{DRm3A4)y3B#A&xq`_b?&?hlk!5QdASzvg+V(y! zAc02odJtrscro3C##^t9^%V5ebnm5c>oE2eON};~xSQ$h_;z>kv5$9e7UsH<>C2H! zkqntU%&rUwAkWXe*l=)!WQ%oMMK<*oKBh~|q$DpMX*EV3vodu&(st!YLRv7f12VqB@W8je^50RQC@l znb^qWU%V2LC4||#x8dkOwzv)Rv;_Hw6XNPyt6%k~=AKEocSv)J@SvcI^|<@UkrBrO za|v4KbU6#(vzybJm>})O87#=e0x#$KyDm}eqQ2WrV~p&>XbEyobLnx~yIDW)50i-S zoum#TJcWdcX-fvAlufBLk++2qGM|GA{Nw5ryOpFaT)9sCd@PXvqQg-3_9-6%I_HVe zvwuNDrNr5tTZ&>Y6P-9USp1QN>668x-bsF~{DYkIqOQ6k;tn}f(Tz;{yeV}yFJz@2 zJeL2N%ZvzlJYFYl5R5wmzksI&X+9fHLwpe z`X(oMP$grs?X47)xIeo?M8Sy;6E0jVt0V-%)FCPY*QjU=m5IzPk;k`{IW$ zt#hvL6PHv9AAk148;H#qb+(+=UW)ZxYFOmH?2tfv!qDY*syXKk6+XNR&&p0rUr(U9 z7=J`muR)CAK+|X|>Q7NdPVCs@I9Zj& zktwQVwMgdbX1i^d1m5kpw)yO5?F$Hfkx~=qrgw&RNF*ouOPEc&=De=(=d3Ek z*;ia;W{mwg&MFx$Pq>SexpwR4ruq_V3F?Khca(6dC5Z`|2Qox2=yB}d$Jjw|Pte+o zI{M06uUj_*6^rOahu&5XWSxkz-!tfbn_s)b_Q(Ne(=^WHWZC%y_mM9C+Mu+)OGlkT zzeLe9b&WI$oISqWBzxh_GL0s)UY6$8cR9h_w92A1OnlXd3x6(mJjJcZ|QBybT*Ft?>ZV6s(8M$ODqVOzU^o`G7(3PgcZZiZ)dwgtJPT&{~mp~ZR43qGqvp#`IraRo0;!Q5AFx})m z?S1u5=RNM#KA=4jwSybb+V1!^M5x9r70rH9k(vrkp%I1@^YDhx_46 z=gpWeZMH&BeN?#P^h8oeke{SDCe6SO-F0^l&)YSaLb;SBYrM1l;1|om zuUr#l7EvrT+0@}2ozdE`-l*ro+ntvib6o4~bn~wJ@|v2Q(MpBceR_r(ruS*Y_`akT zFzpl@bBXg%>)yGWuv{e8TUYYZr&Uf*;>VQviCid|C&u!U2df_EX}nfjf8!Z#tEy;v z-%P!5T9#O<6mK@WI1QkzkgLyDErYJ;9xn3nNNa^5o%he^g##@JjojtmhSKRx4`YEC zcz9wbhTcVmni7{lCJ~DcfKk|@QpmKoO3B7w-=gAWCf|WSE>vk#SzmLA%cp*BiS3sH8 z>#A~qz|#Zyy1P<@>nn6*AKy@%Q&7$^_zPM%$f48KLP?dVM#-TkhR=Oj?HCQRY^R@R z;f_ipvRC|spSI(jxrV5$L%$rXcA7jY0UlHt2EXEa@`-lnOyFc?o#)HJDyKPT|8?V_2u5>6tiY+u`O zY*)k~{6??O5AXa14Wq8=(GRHgfkWC=@XhG`Y0M}eX=oqbZB~EjK>wCRCS?us-JbNp z53e{bDxI3#;dABr;(^zL;$Y6ZI~)A)vE%aY|LePIrGf7E3$Gk2yhi$Og88?a|9|k_ zNwSAmPC<(O$Jm@*C?%PX5p-Sg@1*VuI(G2smQG^cBCXf^x6?M*5MQ9g;QKtKiC|1_ zPIbZ_FGP=B+=mEUU~;)_+H^(Js5K!dH)^N9XvEgDMZP-~dWs}fDcKMC^W2Fn>K{Aq zQeAYv`l#uPP;w_C|GT@9Pu_Uyx(o9<1zeI)s@YL=QS7uVZ50d2D+;_*g!qnQ6Wgaf z@y?2$f3QRLHCgg0|5k}wyXONXA13;~%pOC%rhrZzL`$Ss?`ZEASgA{WR%F+ygBS1m zRX=~!@7O8*IShOm&o4Bhe#Y}x=>+l2oZXo)P(Jr_K;x`jWko9JqwnsnO}IGFTs<+;?|IHyc3(LQ%L|G&3A|~t@};d#@`}$x z+scL*IbU1|E@Mr13>bTEIWOM3$uIQUWkb^f|9J1_zlfweV*YMRhGW3^4$FCi-V0Hs zteK7n#}l;YZM!bSmC|>XrB8pzUXnQ>ukkEopp?1xgh<$PQu~C&g8`}Ol-GCLZV4YB z@D*i9r#XCWo0Rz7bZS1DJ@lPtn#l!q~ix-tfS# zBcgFJ?2cNA=UcEwWMVHaa#xpIjgkK*;C=MV5O&ERKSnuW&=3<%6s-%`vT=V+7> z5lGa!8%M~|_-r@t*=cn$2U@)-cd@^q4E40PEgW5mk(^_p(Gi^c1??OCGY<k z9UnHwW1>zmtrDrek2FfUb&IovR+oXgfO0LF?BV#_L~Ax%k8ahhjHn|LU$f(Sg6bc7 z`w$=Hs9We%d;7%ylmSP{*~j*-LriLu1vkP@1SV)|sAZ(6i|i@#bs(Wh46^oK6n;mn zcVC1|w8)aibKq)j7_fn2p3f;eK8tgdMutZ&F_dc1AG6Wz2{!#$ByDGOjrG-RW7@4m zHuk(LiVAdk?0U177da>=0ucjy@R0Wq12#i7V?>wRD7L1u(4k}n(Vk||1i0;v_y6Di zSNgb!0;-Q6 z-3goP%$SCD>2#a+=@Tl`j&oen8oa6A*>h;F-ugwn*H&6nZ7W6dU^tz9F zkadeHu=Pd!Em8%CrrnRsU()iAw*+RjdDRxHRzpgjJ6|Y$n=tCUi`A|hWjy=cJ4C>kX&XYv>#NJqbLeS5 z(Kqbg#&MkERxSPU-8Hh?w;Z#1q|(l;zgLekHdCF7mtn7~o^{c62Jajyu5La3DA3%q03y~kR^yQxTxRbey zInny)ZeMQs#{*aVw()XX)fjv0+~CZ^Y`Jo>@GVzP4L^@d4Yw)h|6}hhpyFD#wNWAv zAOr~R1b6oaLU5Oc#x1xL+$ABnTO*B2Hx9ui1QOf{9xTB%I3z$IyiWG{_dT$)&&~PY z8}Hrsk23~??$xVStyyzb&G}8Kxh`s5WXzQ{9B;dfX4h2_I^pK)0~cd@?d7t;EVjOp zj*0ColrnF5ju@cb=$K+QBGo7-<^*?&3t`=l6`CB?`A8g7@!_lMfOB;*`nIdSF#iDr zy^)9egcSQ}8;MAVQD`!`26uH+WdbG2hqy@-Bx;@%W7PF8Il&}UL2J*edEphvR*U6X zQ<0}v2p*LLaLX=$gesJXaG4J4ZeNhLE}p(#q1BL#vKsEHxz_lxWQZ}_{wXpC7S&c) zfWHQJgVAFwq5+4Fq>|bP)cS?iri&!##@0Fy^fkqKn6nvnaq@jrUr8R!s(vso)lHM+ zG|Df|1%1;Cfr>-DUb91#KDYU+M^o05n6=peqxmNWx4UfhJ(gomQ}GJZ0>d!HNhQ8? z%7GwxVI8z>y7@k!0%$Np;t^D#&9?C?9F(C`g7mdU2Ddtj^GFc!t=UD@V}Pa~$NAf5 zg51D)>X;fT8U%4UiU+|ogGe|9h9oW0FDUyNo(ce~0uwYMAPmAPApd2Q@$aQ*m0h;d zt^~_rk)IvAaz#E0`TO{H^mdULafc8T(yuN*P$+I3bvy|FSTw;^fSL-F1d5`wj|_E@ zRU8l?bKs18UmdG##{#4~bX1f}NK~IM#!cYx9C|Idhr1bRs!4}?DtI#8F?H`^%8Bgh zb=SRHhz3)jm8cD~vXX#uld4QfPTU&s7zo`JqLJgzn2PclYPOL4)_LF%!2fEa9nH9# zgd>Cu?rauvj1##vuh^c~kQGciJ`zpP9&>!i@vuG6VNrCF-jdOVk?xTq^_W6_VX%wj zsdzLzz=B=59dWuUm-g7mPkS7b&o}g-Q%IihVEc7)CzZwXcJx=%t7QlZa}|f9b49bI z4&7*4Fo)g^ZFv(;sDa;Bb-`dl5F0C+jTFR#^LZ?WB!b^4oibtqzF026d$o0La{v(-qCy zz_@zSwnRt6fIaqYjJudISKW+x%d;k-g~?g|QxWcnLIpcBa}v}o4C*S*NH%~3bQLpl zKFj`Sv8PSG$MrDkJ248FE5tfLoN|aZjcCImxHj7&ahNfzpl|lc^Hhjxe48|wH3gte z)~JZOSWwnm>=z|*0vLX*PrMqQ%rR}wrHtJh-1ua@kg(2-aLt)<@dFSBwrSm5j3D4> zhX)|^RN8S#Qn~g67*lH|MvJT{GPqFUOrkLtqxm~y4pw)R#;KtAkJOdlGFcLf~cQt+4OyBwA9hJy7| zq#$l)a^{p!vnvEf6XtzpUHG_y!n9S9j9~7;f^`5-SfFJ=0 zG}MmU(SV)Y*)tMV==3>@`e?g|J1-wcTatcdF6qY_@HEzrVV37u4Ko+2WNw>fiw^gA ziBIYs%#2;-R3>Op;2>MtWdK28EzSY$SCxrYesh^>wL2Havwo!s0dh>+)5$?N&cbrF z>YPW)Urw%0zLK5mQAuJ>HT_1CnP+~`I))66*^?yWH&*5%;dqpO{|dpfKX*IWMYs?> z|D?@H^Oo`D!^Z&YATTe^7J2xLY#Ry=yQoId)53#_6t#OGexr$7nf%KyR#e(wTs)n8 z+-$>fxrXLPBe=APpItRi;3jE9ZEbTqwps{zVyokaEa7GC+{$N z4^2ts3;WQ^xK_nyd(yzKgg#$g`y^R|q+ZTu?uPD8>-70mB+xE!PfTc5>;- z2b7mvm6yxi%VyQzl`6h1ULh2Gs&IDP1Cx>hgJhR%FS$Sit#Xr6IT{yDsI_=nbBt3X zg^9MNBLI5?>kK|x4P|n0hS<6wa#crO$CqT|cZ#pHrI<7XQt0St~tAqEVe z(=y3vv*{u$4GqF%6eW1s+wTtqFISlOc+wXtJ!f__KXvzWE|{4U zHW8~xtz&-REp(RW5K&E*<`5s3i>GMw)Muk`PI6R@l^(D)lR(tAg<7q%Iqp*G?FMBt z{?MqMZW-SzwpVOGnM^)c&RQ61e3v6!0RS_&W9it1Z~L0)7z)~(kk=Od7<-{=>96!s zT`m=if{PXfOK$%Pq03xYMcw~%mgG0G4z#5!guVjuk0Mb?!)Jijy^!x&25$ztTNL_M z-qbsBDP7W#LFUT12JgR02AUpxrW3TrGMiSnAS{-+AAcy^$f21;&sm)mr20TQ4WIc< zpR50C)2VL_4)O|?Q5%#Y$TXz_$H@3EV*LZSjzCJKbpkmgp?ngx3=3;a! z6Vyyewv8S1OiLXuR;Ph5L{?4R!M2j(d;pL)gpP2zK>EH#m&(#)@jYmv$?GZxl<%l8 z`Kz6Wy7GPeROmZha%^Lr0na(sAU zI(xj4AD=)KZu|DR^JJN?SFN63gU|+rN(L?323N}2`w`X*p5+9!yN>-)RFDEFxm4po zTJwUczt@O4g~kVC^>o_ux>U9F4#fvd0jrF9d47R3z6`1(qXYUB#24myCi%@J#b~u9 zbNO81Pxgh&&TC=Zjb-aP;uFZ#7Tw86}CvNsh)*Tb1AQ!dmaa^9`OB7e&A@@fqdz=H&wYd(@ zdJZzX0qHd7hkqOptbOQUt43`$z&plInZi|1Tuc4L1k-LtCpywfhJe*p`NmrB4 z{w4#WL0fF(i<`zz*c!nIJoiV3jjlbrS%`S+hWQudUK{nAodU}}!>V*U z;svRlG4JLF*hD~vKbhD_I<}BIw|$)Ta!()c(^C4u%Co|;FF9iaOWg~*@GZ{8Z1BhA zovinRK6}@n*K$MOX6J1e>Rq83yz$|z2YgHIiR|UNv7cD}Vd$cusG8e{OBC-@CZaw#C z8El9BFtgfct=*)x zos4{nqBBoXS(+H)&vV#`SOQt?@i9)-^h-r)rtWD4v8on`^97JjbN+&{3|>-*+e^&YEf%&n2hhit4h;XopT(7+$5#KLVIF69&~nc2qzdZNqB{j*dw~+%p-QUyGzP9XGbwG9*|dDbJ6EnK>o{8s9xmv5XyDhN#*wa=Jet zdPNh)Ut~OsG8Dh)*C1PIsW$iFH4KJEW)+>t$UIkvQ!L?9aXC)SFBR%ht)ludrJPHw z)&O>BKZ`Xbp3>Z<&O5d^a5VTpN}pUdy4T)W1#tzHTYhMv9-3-oP&9|5WKP!o5TRA4 z0%r&XrjfddjtV~T4kxVAX&!H$;PG@G&{s=VORsB*4|x2KN7HRUfL34IwGCdLEXHDWk=naec5TWzySe$!nX&V9mnfy{cI!z=k%6!Cp5+nKvRFOv%Kj zB+5{$f7@{uUhX}|N^^ke%R2KsT4yOnY?&AIn{$%sr|~BYyZTOo1@9yqr#40IeSgi; z3YUYuBVzPoU-90UUoLll|dY;m&l?F{9{eiF5XimpXil6Or1^->+K)g%G1Ctovhc5 z5gEMVj2v|a=F&X5eoupSYvZwGu~Y@pElKGVMORVw5L1rzIm)V& zo+13+HivUxa`C4lm=n#1j#u*Lpz*l$Ev$!KU8u~jtDue<{892+po>-{*6Bo|*%-TY zHhmRh2zV*;NfT?JivXY7mKispfnj#?88ie#O{H4l76~2O?2ujQ2Xnq~y-h`>LZOom_#a8> z?9w9Tw1~Si#plqjY1c177hj!efb9_)N9N|iVP5&tQ=O2OW#8Cjxt12$EI zIAkmw`XD;WoMouzd^XJVoxN5$cG?2b8kbvxQ&m~xZ+|)c*;ERy!2o&83t}FmvCWa) zCeeLUbcl27hLhL2Dh$Un!lE9Qz|Fu(U5-|C>)mqv+7-gmN9R7sQmX0)^Im9$5|fxC zw^J#f3!!>~x^Ti*cTTdNNV{Z1OT%GzPNfD|e#zjhqCpu=I6+Rhq|kX$pHOo{QNC&G z<+y)+@fCu4WW{iP=8$>h^A9<5?P!kOv}N(3h2270CgJR+<^0C>&KZoR=%Kw$lPQb0 z(rHt=L>Cz7xF1(IX@UDqB*%WJS?Esd2vXhhti}B)ZSpk5O3!I!k|z~}%65qsOamh) zOM`)c=p5>DU_2Or%*VbJS7EWPnEKe(i)h$hPg&ozWNzT|ISQ_VqE@N48PBVvgyQ_- zoWc--S7Fa1&rRg65IW_td%>Hp1brB|QWE*PFnRr7CZC)&S*6CbV!N!npa!@>5|>)QoZ#Aq-tds5eiKbAr}T zdm>23G1GjO{|Gy^51SFi|637ipAhkh8^3Wep{XB(Zg_&R`RXV`wh6&;6Z> zQhe-AIiZ+qSlpUC?Pjynig^%PtSB>a^4*x2=S(ba5pJ_# z9!-Lotdnzwnlex0`J>6XNnqO?KmQ`uYLEwAOrox?byOH91U62w=!iiP zAV9A-J~z6m%9IMtv7HFFucHo9*V54Ek<4qGQk0JSh9m(y&)G9eqUdNvlDDedM?`@Z z2~!*gC>hGe$IXzY*QAx&v(r7>)zV6WL|_svpeeBwYLg`yDeo?ri+u-~BAh`zl7=w8MB zpEy-F=DMi4ZgKp(%?y99H1sacLo^bRXejL0AMTy3st#a%fd!ntD95658PkIDa) z_zz>9{N?Ku1HZy1&JHPs99PZKJ04Iuax4Nm^hcM=j8?M@q1DJn&)ZK!8kF#3@yQTSbN+V?jGjfSBaKBScw_Y?*6KF$~iO&%Xk^t&ux{I z-MvmLzd}qumtM+eC4uoZ_MeTvwurggvp?JdnakJhZYk9_OBFmhEHV7d-*G{sCkf~1 zE-BU+7Arf=iLqHoSOu%SFXfsM318TR&bT_?Pf9d_1!ecTsymfeP$PR3vGZ&a2ax)ZH>SfA+^r6L4X2TpfbxbwIwWUFZp!U^OqRgOC z+t7K_T@%8Zu_sRn%oo5Tdc1ZcOa>4)r-5J%Le2+`Tq&)3rd`2F(5^mJN%VQ19jwYJ zZie35Qu>VD)K04^q}YUXQCOQMwQ-}G)ZF|d^i_{Vaj~W`bDj-XNpF-ce=BTTO|nT9 zyR5MjRFwNzFQ>_T8bghe>JlCqo8%X!6E7r@c{f#2;4mlOXjBo=xvC?7eyg64M(N{a6~h>VUOcuG&>*lPvsK1493|1b@sW%I)EQDM2y#yzXr!E!r7<=S(t`}h zqsLo@k640HPfvK_4sGedo)p=^K}snA=?wp8*DO-{H*f#2a&~#I8Kf>|OdpwNH7CGz zwucu*>-Gk)uCfY!>~jnZ)k$v)&99zB$CEB^`g^-;2=l!0I!l@hg(9eehK$pxG)5X3 zeKR=6iV~H;Utb%Q8{HO|f<#=0I7u#4seAdIKf%(~T=B{VzJzA^V?8UjA!Oxg5tpnX;E^%5+ z)|-?g+P3MYalC?_SQqm5B2t(mUwp^}Br-|SaZOlir^dKpMt53*c}-JO=>++CfEHSx z7fR>TWd|MS)>Hiz-t(!Eq+V)FrXji zIB-pTC1iS5xV2^up3%o5%e<>j=r#`8FImI?9X{ucb5=aBxXPwWYE$teHFWhJe#GP-pL6umRyp1k5zqlhwAuu zdnhlfpeN1|fm(GcmOaPVm8up=0)}OFYqBq1a}0@l=^6&R&W1`ot;R}p!_tON4L-lU zW8S#_7jONbKMrhqtBoamvUKL#b5jxiIJBOPq;+_lRq;q~ z&9l~SFy-sVprztrzGA0JoQ71oHk7LQX2AJDmxTuWDLWJ?W2&#s=Vg{t!J~?ug>H#u z0yEBh59D$ojU~nzhuw{79F`z`Li`h2Pmdubn9Pgmq4CDkTqNm7Sqs|Z(OLL1bS7Zt zGiS8vvdH1ff!OnYVME7+NHgOx{>C(^dbun>S%U+YOOOWTA^cGu9|UF<&9T{3#YIMv z(J2nl*&zFD)wQF2u6GDps(azG!{*qH)WxFhV9qdahIQsMw)*A8tu(QqrdA$HA^J(dp5zTy$jb%_Bbgr7t2-#3!c)ynficw6YC&?^-py*6F5?fm=l=#gtt=0XU!J2jy zRA)m;_o7b645gDrs6PQixFBzEELGx_=AX|`jmSEwp54>ku!saL=Urrdh@jtdLN@@5 zkVU#eu|NWzfRkzQ3}xw&I3B8PN+Y)x{u&bt-8L>UA=r%2IEx=C)L0BH?wpC=+?+E* z;f)}-eK8jo+Dg}|8raFa)Aof&RZVri`l^14VGc(1+Z^WN!i}CCJE5RpE5ab>3=Rez zhdw}mewF9Gz4FqjqoLRDIvs)umV=ogD`f+ABnmmKML614#)A+~!mV$S z=>QyRTF>zQjwvxtp}f~fQfwdfMt7QssWPF%$^^3KIc;AW>ogU_%5n0GLf$n2`UI;C z%wv1YZ~?lvPf^uN_H2zO;t_9|a;Ih)sqa4gZdla?lV_|3qH|Q!pNE0MCwg%v+T9ka z`e8#c^yN_~=b~qQjiW(g!M%s4AEr1<4;{<-zkj^uD*<{3==#Is2MKd_*G!uY>vY*2 zug3X-6Yp{`iWx<`#CJE3A&U`^Qde$Hk)KA?lcHhHBr||K|9w>|>9ENncaR;P#rBL# zT1r#;3Nujws<&Q^Cl{T;Ucs%$&V#7fPTNa~*03R?gL6lQ48(U2t-tZfPlTB|N|H!O zcWvfTe{r3nNenrg(IptkDpUfk*?u(LhTJ`RwLI)H@rGU#S8EQUViBiCnc$#cWH^xV6fW}422YF4EHcS^s4D2w< z!O`P4Iw!5z239m(Je@p6J{xSZmWiGpbcui@8AS97+L4`8T=_%WIVztSiRnl-D?n^V zW-miiJnE10PYf(vY!J%AJ}_>j*6zrRU3P_ z%*euFmYqA^#UI0~nm)^;#T-E_y;2{P%rx1o$qoa;QCOAC=jgDaqYH@Ss^``qq_E2F z3Y@LTReD?HjFTW-qCDPRtk4PXwBpXgCw!@mI=Qk2KJ7%!k{#=`rbrCUTWqL>!(0v3 z75Sou;p8=v9khm4&nwu4s&EePN-7x;}8)$w8 zVT8M`hnp6H3Gza7P%DISwEdiz*xQ+hc=J#oG1ZB@)c%3f6al=rt*+PUtRktYiT6vQ}v zs0pX)g^63EFNQ48->Zwfyl(&g-Pm%9Y$8dxj5#Sg7A+1YfumDn!f2mY^feb6{@Jhe zw+{$PkXWHzh$U;>;Yof7!2^=uH$|Pu`{ZBX-hhDbehmRvcMxye@m%a~k%`Sy@q&7> z{R0S$qp1@}n$+KFf}IK>3Lg)1#y}bThAQCimnjmuDdtb9OT%yGe2R= zXBz$TuHMXV8|%)|TQ_y@VW!Ac~b?1m7eUEj@V-T`hbsvitO!YXrZ6y47h$zP+P`Vwmja3d|{v3lj7^ zVHDm9k)gQ!_gRYrv}@c|MAdRx>4y$1*)^-L;Gt|a=^!$3T=ucR|p^d$C8K|7^bq` zsRM|-wBgCv%GM8O0^M>*3#I?~g4lOKUr49C2mA70zx{cVv{3Zn+TEiV^b6y>TI3xu zL6zuns%u}Ul`n;sdHisX|LK~4^!$%su#5MZ^GZv)stT@M_-9urQO&faXQD5Lg#Q;w z*SkXV5XU*m(fK`FW z;`7#(oAN#Wvft|Gicvb~xTdpmf;irG)}DjmmCN&m?$-9!Lac`5$&#Aw8MGU{pFlD* zWH0ZJp`pgbEzd-3S|<|FYQ}cn_3(XmJ6y-^)~DeP_puOP{{Q^*%Mrghz zjfMG$X9{I3hh0lJkkU{#g6N(Q)FdJ?erDf#ON5J`J~G9Q0txGddD3STwU?khj_e|9 z13f=)ItamIX^Vd)@2j;O$Zt;A_Cmq```S%?EB}?H0P8eF$WyTUnzNZT*h;<1rWsyh z))Q3&~gJ%HmK9wMVgzupVzh#D+`|qxANr;-Gx8@fF`TgJgc3&H&2FP(I z+$UI!EB<8si|807gNPF30SmuSERJlUa0{W?@tTEju2Ww#f=zfkGF4IZf|eG9$Q|AO zP1S`mf}rvO|F@s{c(OKq+(}`2Up(Q0mYa$*-DW9!4r^k~fE-XHV5ApRZtahBQ^WqP z(7ZqlvA8=X>x{gV*e(r8&|+`r8TBfzR@W!HM;Ps(@QNQ)7_;_QocFhKVE_1cAO1~8 z{~rqZmwdaCNJanV-QO;Ve@f1Rrx^V}aBOpR>GDe?2P=Cd*F4C7h0A}-BzOwhKL^X< z2H@W<6ShUDbO{qwGZ6xozVKL#CJ(p?NPnP?Uk>s+S}8~1ItcaiV*aQ!O2U^ZZj^f{ zq#7@@uwdh;0$qcB$Kfdwb8JSm3AA<+%XMcCX*pD)ETC~Sdc?}l(pEuO)^1vubSLF_ z_Gu>VxU#ot^opZjyw-=PB!v$6q+m83EOdD1eF7EvHbeqCU<^TKdH1Ud)kMlN(Pal%(P+KAzK`{XNx#KW>ZB~M}JIK#{JD}?zjS6vRAWx*(y`>W;dc*4$2m_t?& ztb_rvP|r{x1_vs|8ij;V2YpCN5atnPE}1co_fAGcxW5(b={K;WIGH4C;f_-_1ghqTZ;#`gAXm^RjG~kH+f=()}d5c5**CTrT--oXd?1D*+ zYnqy0lP~78l~-ILu%~^&`F&0> zATIUT;jpd?!J?#+WXR3Pa(oCiG|-A?ILS+ZOqL-B$s8L6a~KErb7eO`BdG6xrs21h z51oC2Wv8>_ChJHcYVIk^uN8B za=O3xZ}!nuYv+E=HZ7QqT=s5}nrPTkJwxH)0oK|`ttG2=qNzwGtH+NJl@7Frw`j}s zx7)sfIUb2+2!U$0xC1gMfM^_&VKz#u#ABKHpv{yHOleL4Z`4o)tj|g`hiWjRRm{bV zrjc2PCCj2bE|nTK^fHIFw#qP8xW_##n?n&WEh}53O0V~xSLt@#+N6W|>7 zb~#F+;bCeT;%5LP0=h4XpMAKV%lyyq_AhHT=JOT8Sfa9dwdCHDUgdhiuCM-#4a+ZC z8{B53m=_CtfqO^-op#{~bg6VFUpzfb?x$LrtIe+yBb5=H39&cd6VQL!XW6SM@akD? zw-t|#V+u~mhgn6NWNlfqzC1c-F4jO>#hEFd2KQ~}`VFPXe151*ZOM6WzeleInsZ86 zN|IBui~x#sk(ky})zARzq2ubn@q?{nNP6^o4x&aFS6jQ#qWM=-SSx^JLk+;nXX73;`#?&h2kv4?oA-{zx|n7MD|&quv~ZkYHX0xOrm^gGYL+$~ax_0sA%MvE$Cpgsi=#=Q1bM(iDKGl)`H?U)ZZ6Z zj+a^IPGO%XE#GYX2%__U+*{S0j(j_mCj6V!I;w-IC`bRe4$y8Lct=u-}WB7(r!u3BR8 zk!c>6%PeE1LyGzIG@TChgVWbiU>aN)YGy$O#s>w=}GS6-sQ-o^jAd=8;o9@ z9FzuF)!v*8F+u={q4nG|L$vB|oR{Ak#Bu-%1UW~g9)=9G`!!0(k?&SL%Ac2-F4?mK z8n@ywHd{ORc5lskc?9WWvB}}vQO!E-R!JgoL;UJM z2AO)?nk?RH9t)QcWh8as6o28l-l^AT%g0Kxm%f<>gvlbFT%N3^lUh~9L{yAcRa16N zxs85_LUJ8zEQ(+PM#~2fY>2Cg$|{s;B*HcoD|Nh)aQ2u^N)s|U*dxDYmKi_KV9#ko zAxhIYOh?agS4@mK+c9xdHxrBvOjX6rs8;nu`JawM#c#jcK^W3139{LV#c z;ik>3!ZtY@nNdih=^sLi+8fPxRwRzeE}opU__$3n8)V()v|11IfevSk#qj%JkM_5 zltqb2HxbSFE&eT%^rNYFoYfcdehZ}a1+vw#w(4DNy&5+?(;EOPLfK`v+lWzuwwKnbmFHNyBXtP%WRRlWiuc$UTjXYiTTJ;b83k+8H?T`52LTmFo7U8ovBf6=17p5fd+cLocNg zqnN@7^COMdbwZSI#4ICQu>7>z|@n!xK4Jnu%MY)3WC=w}uJDNE1qxKDGQHHc?tT4rZGk1k-I%VP=0;3=IJT^h&k9_nJJy7S#qEoJZ zVd&x3(D=AgNg;gO+P{lVF01WV>1XeJJy^(bSM(1G^t6N!bwBI`Hd>P(_Wpke$!wSY zh~OyQfbKug#(v)~R4ZL~aZVFQfcD=Zx$lI(Pz^LGUrglEhrxV5{75$}oF=^Yi+Tfo zgsdT`cqBIwpZv4CnDQaNx(Wrl1T}TI;0Px5D9@Q*i~Z}BKQ{N>1hi<{bIptsfvzYM z_ZD5S{G8%c&dA>x_}x@xe?)X|0_Xr|K!6D!JuJf`>4F8GauLoNAZ{93QI=_<^Lrg6 zHNNd+FxcSZPgb~K{J=N5Dz?f-$E@h}TUWE?ymp~0xLsC|r_xu!TFz8-L8+l?>I_P@ z4UZe&B~H~UO&hyuyZjM8y@9uV8ERh~YQVt5;!G=`njW*eTw2%zQVU7T4)QM=Xx`3Y zE!d+t@ZD`74$yn>jumMk`Obe%{10Wx(|0WAE3fFpGRmHfIZOgJ@78j@P;rA5pZ|JB z@pY?ROMoxe+Z=U_hpCJ$*2#_h=kI?$2LxoY3)=*$&6iJpE$;rCeEa{E&T(@dT;CZD z&(OyDnW3GwTy!m1_Se|J&-5F`gYj#j%)f>({z$f9w%Z~kjk;E%L0AJ=O^pT7n# z{*Y=dfNNd^UzK(oM>@z9BIy2M{GsN%mCjW%1q0xJKK{>VLGLP(fP&}!Y?Zo_j>2v< zqwc?WDF4X!`b2syfYp*z!V-axMYz^zYy3#ng2Gt~&2Y+LNcaGd=c82k3A>oDV=L-E z2mk-&L3TAe>!zO7z)C5#5hGfiNc_BBGm9?&U!Cv26k{-Csx)G`4%!iF^zWwQB|S@U zkMsrAkNPTE1HCiK_fntJaMFa67)AWWocyI2+w8Yd0kQ_|c@Ac{-dogouwQO7bi_FP zo^I_gZtOqO4sXbE{Rd6FWnR^TVq0Jl$x}lX_)ggev{ed|d5KFm&Dmexf1{6qlz~C4 zNEB2I{Eq)jU0nv6>4?q!0rD5u?k~BKOHe7XvbWzEa70?u_w=UI5^w8A8HyWfg(52R zv%f4jrkx({-c3$;cmtfiWtsSa&~FS=oLEHUaD6cm55Y9j=B5vJ4T7K)1ln5f}of$8IIdzkwqLd6Ouz8&Ei?~b)1C|C(Q2$0ysWP z(93LNO9!YwusyX<9UnWE%`Pu|N#uE|7+TYXED>%ENr8<_jCJw`*D2gq#I=*)DzdgZ zH&fA63U*~t7db!v-=VvdVqda$M}Fkt7bEAI;4XS8U2QA-@SrD9>r~ai&1JGfGIQtY z4po(R_65?scKzI`#onA>7L*m^@#&D0$trQUFbAL0vc)7bP4SA+8>2Jc^p zUGfmrY>S)}{(7QhF8|2fyMd%6II54}?;rDhkgJqq+u)JtGWc31{xN)4JAPmydV;#T zJWyOc(aFoH>m`?*TtAe9|#RYuh4swozEfLb#=n*QFU~$JZ3To5?VS>VF|h$}O86*sD&Z=LJhFS@??Rf}}(C zBKV{ML1lVg9kLm`nKnMz*#`AWxj2I@I^ybTcLij^ZSBx4m%Hv&wsIx;%`s+L^fKq> z`DM~RF))%6BE?_$`cd$kZ1(?Bg%;hA% z`3-xab$&oPSSL%*t2>Vk<6^C@rYk7d8)cKTus3PEI0IIcMd_4`%gWf#+!&lADXUp) zhus-qmup6NT1~bL)l<(@%xy;s;W9X0RsJZn))~yAA;Zy*2=n4-CyGn*d8fvAz@(cL zwNaqNS(*prSFbLzVWPLtAj>4^%jM)`j&ArcJ`9Up zS!p4v&MQ6zFpM*z$}l~MQeeMA(EFtZO5WAp<@u4h_xoz@zY)#9R$1_TF7K!x363{x zHq5T!;fuyovpZJ^_%{{(Ew;JpIh%i5SN$sy0-*z*2 zCvU>vIjVTUYA7kx_nNy_6-zPnx(ShRYh}Oa&&=D6Qc9JiSN(ahTdK)@OIB6m(Gv=R6;U7h*8JIh?S+ z8_sC34(TJD@cdjZ?jowCJAv;i7P*^LPD~xNbi(_*ct8cbT3IgFfd;wZe+d%D>lXWl zbm_Q^T5dN*#(=0XyZ~JP_(xhAS~F~~{qB8t1A^OCTrV4Y(DF-%-YvI`v#)Ha3oJE% zx?TWB<@^->z&^Y>_z5cXuAA`ZUI%X}vg%n7qwwq=77J*V96GrPUc{hZAyDPD_U!K+ z0t7FjqyISC_&@8Yy|w3Gjp(1o{;yj8t8;J;{;NCu<($y})4cwtG5ifJF~}Ozk13D@ zniOMu^@N82-`V~$d9uiggVXy^=*WM-n;UEQhNB2R)vCz`EWvkOz^y-8dt6`Xe{Efl z-fY>wN&n~v!shBuxaI=A;ra@h>ilVCC>*T(E1y=*$$@>vk1dQsp4WU;-*qP6x#)q< z{9C_iUCZzi-(H$GgjWuv^*Frd13NLRjixn}=N=FzN-Zl98mcpwD5xgTK zz2ACzY=j0xGYTh*qzI>oeAvd`PVgd6A*i6AmEQzI3bPXvo{mT$j-wB6eTRUm9X-0R z^4*+OY*-z`ee2@$iz&34~225Py$nulN+0Jo<~N8i@Q%+>N0!%4!RyD9c1|1`GFb84mkS`+)sa zb_DT7dp*3`oiS141E&RU`;{+{#vb=#)dw608Q!qPR5_c&SILsXENPp1Cv{viwXYiT zC85JrN8(9qx1Px?WJk{iv0I^FuD#La(F)TgNAJqZ~bv?$P=dAT;YN9TWIFH zIS89I;w&TiGpMMO zRyp260IUz4lSXmBLLgfSP)M1&?>Q}#UiPH%OviR>hA7u$px6Anuf$Yf&3H#r&4qRYr28q_C#U0JYm3|f}D-t#WIz&e9r_`Y?w z+Ss`8Ug^afi6Vuf!+==8K)@>dBL8srMMvl*_-y%GbHO(*{jZJk&ta+Q;X+_ZT&_3C zpfrE{J^b_QyE^}Nsjx?In*>Z{tjv$(ms{u(2{j~WmKO{6s6+4Nd>n7g%k$IJxuw8{p(6U6HEkXt=KC`4bg zSVvdLxuVrx&8e6xyvXib3Sxy0RA6yAG4<%ly;+VsqVy|Yl0)hfXv9~R?S zrFISF#d^yzYL);-5hNDN6R<${!QmAMPii!vm5&{!7{e$4wHo=kLMmhoHh~AF$|t89H11@Ov$is|CwSOMo>%?7yhe$>;>5o*L36cJCXj3Izxz=7`{}+32 z0afMJ{R?B!Aq`T}-QCjNB_+}g(t?D7G$IX~ZrGcU?oc{5-6eu_r!?HH9?vTrJ@0wH zJMR7dcZ_!oH|{6av*upUS~Grgt~n1Phl9lo+DOnB14Ux$UyWjp!OmSk{iLcX?dQ1)<*moIMVnNbfsX^?z0n+;m!axNGNq%}EQ{ zD)}vC?jKorZtkIg5NGifN&EN2eM^}8@1>NT>C#Gj`{CJsLxo|cjD~+ zR)nS=u6`#D|GEghNgTi1FNteNXN&h0V=DIL3`H8RU?bI^KrPw`7< zY|2Cg5+O>4$GouV?=e8CWOiZo&(*pFEMbR-F{7f{FbSBhMI--ij%H?p%2t}_6OB9i zq9{Pp+fC->QcYS7CB8g+;TrwJF{3wZDwHQN-`4AmCBYEF)Q+#TSca6&wy{?K$oN=| zE1g*;zRJjV{dMK_==hC?JC=Fz3u3O2!ZETSxqwp|C z9-zXzQ-4oq5`HW_UI_?wop%z}(|rTrZ{&~Dg{e9gAv!j37GP+Dy{7dEtq=Bm(_y); z>#+TwJ;6tFQOjF5qQrlH|C!*i@n1rpe@D-6V$lC@J^l2WzTOWBG(Y4ttEs&Aq@Yvv zE_LH9N>+ub+EyYmt4XHi8BdCQ1^bLREt>||Ld)-{&=6&3V$WG0E@d%ykB zH&OzA54=~G3Okm|P3NZ+C~zN8O2aSmH<*sa$WDs3w-}+^@Cl9M>nc^Ov;yu+c2(IZ z*HsluJ}P&3>mZ~&J7*MZQKASljEhcc%gVd4!YVe$zyI%?_Wtz&g^ZHaa zRBV!a=b}F6q>;UGnEK++ByG|VRJY_oBQ^;ZP8uffOt6vR1FZJwTDrY6oZYK*_GiyU z!-yEVSo=$K+7EWoNOVoge%+IBnOH$=r^ACAoTDCBu^OYhDxZr5oYsL}>GF2f^^~nI z?6Injcvt_mdBncd>CrlHZ!NIkYP&^QmU5G2&RWd681nl&`XGxnMZ5NImNEKDtZG$?*A zr#?!OWY`oF9(^tbo36U{$&#l9B*Ow=qiJv(B zpw{+RswqCh|o4Ph2F;0QBQ_2IbA`#838 z^pA;E^sDRp_${eHNiKYIBdT8Ye+edp$GzPavF`5_2p zb`P%M9cgJPbSK1WavVW9JJq^(KZ{`3R7km+{aS+DWL`+~5o9%4D>6+w4#&Dta@}n} zv(&h3CcGFr2kID1RNk{=xolHXjnvR@GJ4 zu}OFihbj3v^NsBsn>Ws;p_W^w0=rQ(COT;OhP(YY{Eaa^Io*S7VXF3#sklW?WQq)@ z?MO>_PZlWr?WYMl`65PKQ3DW% z+jIK#S{j4jagrNBbibR&H&M*rurW`7aXUhHRY+rew0on~q|`(NbD-)> zLc^&0-n~OvSZ^jO3`8tr$(m{QMwA8zuAP#119Y^-1ghF_mZ{| zwHH!`9e`h?gULMlVYdjXXaMfEUBZgQcmEDO4ruh8v23`IdGo4BayyYx<@90qS42g4kT zL^qJd-syZz4Et?hf!2~#o$@F>q?%R-7Ki12yox=kH+;%||1S*?8N2Iz_ZXKGm*lyh z>Y}EKn6E5t#i<)6JO8sHrx_`$4wD&BtWDbUgjSLwa85b5vyE9SP5Gtqa1_j9Hk+%T zxmJ`3ZFKkhFKQJ_Zw%`t*N3`Qs2&)r)O?~*p_dwHbv=peBJ`C2;_96&79F8py3pV` zU^y^Bcat6spb*11!b%Wm1t!G#BdG>a*hZ=$Yro#&Q(|YQM<3(wWOfe6NmZXu#O+=5|+J|Np5KK3x0AF-7#dnT#-_B;5`i)%J+Qo7M7_5^&*!|7^b450TfcNd;v zQacabvG}}bV*OlP6>VW@8I;&n?F-5Yw3+5cT1Q;WC!=MhSfYbf=XQ|608VQ-o$6)A zv~LC78??BB>bvg6`oXU0udAbspLncBrG`)S2Xr${$WXc+?$0ca%HHea%|0Lx4}L&5 zKq`|{)pa6Mo@~qy>Ox$#?~*OW^37n1jDOvOM4bw(v@aT*kL5HA1jz>MpkbksCuf}X&jri7xfWbhJxeT;Ebe`B1 zRPwLa{mn$>?;E_W8;u!oe7`Qh_2?k_EDui zOHES_jsev?lY|`kdeIP5t*9UZHT7UsTGThqr?7$AI<@^&$dtLr=0N4Qk)kCE9WqpH zx;nxwGzAK=M#2Frm00IVDOUU~FHp@eSG#bFZ2{(6Zj<&G2GrU56lsd0;a*DjC=ThH zcBK=o9T4QBB{{&*2<6rWbBYL-%h7QUZ1$NmVcY8KjMoV;!OL(4ELBPAh1z^;iR#Ei zCikQBQyp23q5&nj)3M1$)rD~v50v&TkUAD9u&dwYWddoshawW?T6JUC0;3Hp%EdGA zDEdzUOm%KkMz#LI$n&wOm=<)6#Ojb5ECJ5^rS0d}6RrHIrB7ei(k}^zg)bnP?C(Gj z%#3&VZcM@W(2$+#;y9mQLZS^;n(?2V@+u+MiD++O*<6@I_Ld(&cCtG{K9DjH5We{B zW=H`-f5_)0t;-Yxn`0%yIZkUwvM`2 z<3D@|u-w+4MLI2I{_%|c>ze$`2lMw#+koE2SQ&?^F32tOcD|53c^+(pF4>=~n z@T@wJDvY3~SngN@ZdHI^PMQ4o!vEwUf!;7ree=#XN|9R2*uWW5ItKPebekabVmT_IT2%{-i4udxzxdIAIp-ZSBM#Sw ze6@Rh%+hvYdNEv>Pfn4)v~NNjY;4g z6%~OJuQ@IFwe+siYPQfOcq#*L;_Gb8!(H>;SD(kb>ujtBcX%2mC{|2~Sw8Qap$|l& z0MZrx2-kWes2H*?$G*k~YYf9(v-5@Tl*C)ueT~Itq_DE5sAYQOb|clq58|ayua{0) zQM-cr(x$P~BOpN0a^0Hz`X;;}?s;;LNB;yR_{t2vmN1Auy!{t+@E6GD?|#bfAPWfR z()$Wo=qX++i}AZ`9Dhgrxp3yh$pZB2wwB1oTYpK)@!OI53QZvxb?36Ab`2p2_&rg@ zF&~iaGQ^}HqRGGSH2}<{4L;W-fMgdxLf?7t7Z~o(7k|Cp%6G^x1JTYOA0vqBS?6wN zy+dBogVVhOv)VhbpcDNSo`ZyPm^4B{IU*WT{|XxB$G<2~nzi*)wn*vD z0QGPL_;YgPIgm4GBpbF%W^e&X7S4PlDSG|NOaxhA%+M<+*}5V}ob8ob#HkTOcd$ot zxt%_i`l#|kedI(m;1+MwG1DZ#XylLt( z1qqpV-LGY#!%K^GFB^P^FuYv-)M8CDYhAT8w~q)|5_q>t)2da1hcg~hz-h=5gR%_` zGHN-p3~5P;ek;48;yCTOt*QK3PUDm|fih7QSsLQ2?9v>WXa8IDY#tk|QPFC_2qig~ zYXFqYqTWoHJim~MC?CP!!yhcR$hhY~8>?mj)Br4vq}%kL5Pafp*Pueskj;xx-+fpL zKcLFqjaX4?Z5Z$C_SlIAS2IdjL)cd>nJ4`U>h`bTOo#{7@~a1xcJ1zUKnt>;dF^Rp ziF$!;Tw)WMZcCMc4vXIhukQ!%jZ2}^%?;am0}#zS9DtMRCfrNnbvW6>{?x3mlU>a8 zQw4hW40O>B-z#&qS37tr7r@y(oe`$gSMA~PLlQ0@0p|%UXKKgN%Z?|bFFTP7gdOn$ zJJ~E-_X1l5{!=o4vF-YAC%|7*`uyFT{#A0GzvBbvEPrtc{M{J;C&a^CLFM;e;u2p$ ztrRug43GIW3&ffGykR;QjZApE>pGnHTPDcjv?m+AplOLQ&0y!r97SiskfA9MIjZFGt~LJ>Zg-CL{(SYuJvR>jZ2NPQ zzkkAwCN~a$e$L;2|79=#w#oPIzTfs|*MENbx%uCpym9r$;m^pZo`Z$hj_Y89_Md-XvLF`LXb(txgJlkRb3oUcs*#Ti?&l zf3n?u#(7+YcdmnU$pHP|EB|ke0ZsDZh&%)c5*q&+e|5v}@I&8JxuH7Wu=4+8nf%w4 z-kaw8|E0(6pxwvMT{l_;Oe7Jj`m2(x)jX>$>OoCaO3@6Gl2_C_|GL2&KMMmm;ide7 z5!oOyp{u(Ja}_<4dqt$w(jT$0YpQ~;fLGsnI3F+2>wRx&ODK&=_7gMZr=2468SnL!5Pbfsb;X4p{(5O3@{}=`pSI95F>xCMP>cqYzOaBsK zKSO+Zq$?o=9HOSHuj*R6mvR47T5dvH$6+_fQS0-ek6Gr!JeefFO`uYBoQ6^lV}WdG zm#-FINDT0igHkLuh4jlSD7r0uILA)At`H76lE9YMBa2~VVyUFN^QB~#uyX}wYA2p{ zO2asFp*SH7OS!|6>On8+_LZC5J)dQ>+_j8$Gf82PIRpiEywLHTqm6l%uST2kt~)YV z3_WBRhzHxhQM2hG?-AqA6y$AG9brO&X1acd&OUc(PCi{WnH;gCa`Tc+O1oAkqC8IHqmnGHIK#*t0(G))pL%RH>n;+#nioSQ|as8PdC*uX;c;+ ze{msbxA4GtVYPF&U5%fdX3j)qGBGcG1qH)KI%z|d25$wUkW{iQ!q3$7i3T&ow!zBEHzz%TJL@mC6-ld_60Ypj-X6q=5+gRvo#v2+6 z&ts`k)7yVuBA*bcg<0dDCeMM5zwm2kMbU3EK+*cNLB_wkVi zlfXvo#o5ON0?vZfwEAF+sFmgM2Z+ZFg!Z}-_YP?KbP z5OTETV$cIwk|-BSGK+_#GNKR+NxeaXtl0bGA4Ad%{j~~Vvw#!NNqrcR{Hp-9&G=Zd zCu*9tkqKI-uIqL}M<=BRUo^n0D>~=zqtq~YHtfVSn-k(cKX4m+h!!JiZ?#wU8kg~Yo{%%Q;ajyhXK z?Hvf0B8q_}pN1)Fi_NCSGf*kkq(*bMuK9CpPm4GxVRX~YVg@KbTy5)UsKQN8LQEm- zq?5#1_-wq=v4K-8#XJy^79>Zj((5B$Ydxy05`pmw;6P$ELRpUcSOsUhAUAos{0gep z?=Yo&NIoE@+SMEwz2w41K3Y%qoHRo$uUEOl%=S-BRK1>xfL-=K>dT-<%Mbeis?Ti?a`O?~spH*@!RLt6}sasK<| zKOY39neQ+P-q#E%du~19$mzi<%Lc&M8B2#9lvkHu#GM`Ro1$NCyVxG>dSD0$#$pH0GZfwI*# z<`^MvLVQmG&S?UVv0k3amaKv#8BAR^P^Vx!R&v}mJ`9Nz`NlZO>K_SN5b*WVc_MG= zyPl?>GF7%hqLqyGv?G9you`55=8y!;JnSSkHHuX|w?7od_(_d0VA_gnQ?gUf`asg< z4U75SLut)E^LbZu(yo4-70utWPvcI#<`qQG|Ccanx36vhDYuv2!#u8ukIxCFzm~~5 z(6^YjR1Da-0u5L?evKXb$vI445f+dXkJ5~E31sOkw zD~|S$#Eg{V+6y|X^Vgloa?`99 z*!;m-3aaXfV;xs3NeYKs z!zESknlN&~=AwkNmF=pHC`wZ4Pc*X2e}u6()OO!sJrT2|!syEHT!MQP$n%ty3dE(p zOkSIj)lBhZtRZ=0;jRBtJS+v@K?z8CV^u)^$=$W2J@V|Gw@6tT`eYbKoQgd9N z2SYA7+)t*A25yP!YsTrc(FS0ixPaxfa$pvKEIK=M(f3L5PPp@&cX{l{lo)aI8&ZK| zTjKgNh8#e?o+bLO#PVKyicoWL75JbPtsLq_cgZnT%@nI>D)9lb^V|)Q@y?aZZ}Nuv@4P``m<%gz2t42ZX=@2P1;m(v;4Z4{oTd%}N_8c^@gau; zs2~oqAcm_hswfu{ni=;1jat%4Pliv36KG8xc;!^&ciC7dhpROklSrWRD4vW9d>@7q z*o@~|`JH)F5lzd0mrmIxL=1NW4d@Eb#*!6E^e-lQH7cqZ6KJvK_QI8jN77T7nE+D4 z`j4DX&L|mG8x2czSyUO&8%`qGMm{=*zgh#wd-RF1G3?Fd#6hxd+nE!$bz8bu$jU`y z0P63}x>+90VkK-LH>@ucXn)3eHaj1zaD5f0{aD2UX*XB#ywLzUTzUd$uT(i&tZnQa z%0g~H!4q55QuMqfj}J0+J;R#h*)6LJ+8GmHW*I`u{CZ3_nO9?*lG3~N(NzuOUEE$d zyF`+&8q>w9&^QJ!YV%EJYn6a|G{het4-RqyyshE(R9$3U0gJo0^~<|Y4&kjQUTAh{ zgk`};x1z^|EWWUeuC#asmFcqgQFa5DJ)}!5kk%#f0VlzT`$c3dNUyj@`t*fOrCL^) zYmgY4QM{@TUq9vK!i=Ymx@6WM;yyX_5Hfk6+~Q$gLCPtp*L%C3{cJl}?g&QDX+N}J zH-yV}S1jkIi}B#Q93xT;)ACnf4gO;%Q$q;-Id#mTdSM!Xfm=n;mu*>Wg$@)uVKFkN*t+nJ0v~4PWL7r(|-d7Ep}pLVCH1_Q8GeRjxoF zJT{z7zPD<}0+{`|&VEy!puSJX>Qhx%HIQT7+E~^Yb50VElV}&uTGLt}V{okk*{*Dw z@~~s9$LhOT53*J@O21z8M|sxNjq%^+Lu!7FYjNPEHonmul`I8#Bqvz>mi=quQ!OlI zPW5nMl|a<79a$-AV@wZI^;5-=lKK{^cNvMgI{u=GjD4|Yq-{HMY+Z$z<^i9AbUZLr zXY=!&#^a{YQ1Y80FdT-K+x2Kcly}mHUpA!l(&3Riiz;HN#0=I%sC>;T_i-E~+Hx=ka^qD^nxOy?wH5&eutih=y@mf!l(> zS_*&6QWij)T&h!R)JxVfMJB!(-upaYRV7qHYNR1CTOXQ=^)XBFs|@2iFy**;N0^~s z$V24^fdo3e+#od4kRk>Qnsl|CT1`m4hzvz;Jv|!vVOPKOaN%_i3VhDr%el9?k~ies zC6*c>-;Q=>`FUCeB?y#T0K9zvOp}N)^U#a_vU8j` zaiBB^oQ~mzr$kD8F-;Xrs9ENF@6mjNd0OSrH$KzvcNl8pWt3ofN=G( zWO*g9_R?&B!6rSD#b@U7LO4wm9fVkn&SfIxtR5jJ^N$+$51k}6ZXLX80g*~e0LZNk zN-VtX{WI5Q{u>x%fScChdKYOBc;f%g)2dVww5M zzMU1JT;G}55R2Zh*LmbohCsebvicILoX*QrV8f^Ntr|FRHu{4a6xZ7H$D?&Q1x7TO zm=@8G3cNz2JL2I_n`)Y@CKzPQ5+#ZaK6gd47y$z7h8Y5asT_2wvRU{z;-A~R@)}*C zA)Ov$=*hKX2E`dI3&0?|@&QZ9(XA*6XtSICy<6XkDsQR}da^%S2&#@$u)ew>!TO=%0QF^zh3x;u~uDJWD|XgZ4Bpyhv1(z3lEcKH~8ar7^3yzQ^6@|ddr5AD+{SJL{1_8Gz|FTDAH zS5B)qMzAYrwMS*d^g&2Pbnj(E5JRg{9Zs{xuKCw_(_lB!z<}d4Z+->k%Xw3(?NwUX74T-tDqbU8lj|R?OSDP&wJ3pRFM9!VZo?Lz&(VH z+(}ZosW!S}Jo!Uy6db}l2+1jxew@yh`Z`)Ym zKaXg-sP3~%%-rBF*A#aTW1@HP-*k-7LpbF4pAAUz_VHjuw;c!=z@`7%&N=-?JM2H( z-MZe+SZ?DIy`3{yt(NSG9W;idXBI=M+B_~bj1 zK(Kb3fbA!X^BhME>rQUioIc9D>I>rU~pYr!+6+oFP?vgHf(bx5Bh%s?}iwj zFn>nm67iqINo(Sx5DLM$xE3TzmvK1v4c`iHNFyEkSuBY%ea291e$C~H9*R%eM91t; zc#U(#{7LYvBI*s!C0IG9x$~EPCcgKRe6ycAp47`zIljq&G}>@Vl*?|YZ8(_clDthq zAJLF^!dzba)R7U)J^cO2hb=K&AaaZ$iGO(iKYa2%QE!m`fA~n}PCKGZ`dm!%(p5+S zNE?D@jz6a@;VX&!V}Xggy?p!g0{aFgNzE?TgFa;t02Lf5FuUW;g6S-dVLaz zV_ojWAX2E}w}-M5{Ry$me+Xr2u^qvGcN1rUP~`l59{z%&y1%Gj{$4js=wwZ)^^$M+ zC@KFViR|mh#Kqjd*RuAy6m+A%q1d=O>3neijxw{O9r!$IGHzI*#N`fbRGyZ4|`(a?#RSy=7tBctwPJS1V~cqAmO zSRw}AABM=Bqcc^T)eN(m=0C$?ldBu~ zWE9O&h`#jo+r65Eg1vPM3R)2A3hGGWeD!I$!ei@7W^0_!a{wdK*IHQo&EF@fN}LQqI%Mw+?SbmKi(yY=&m)jolV^MwGVF1*`R!mTq@qHzmGPFSW3y z^DBWFdCGV)Wm%^wc>IRIk(2g*yV_j`AN-+WrVcu}WW_?zV=hQul)U_IMQv@#8b1u>+v!5ZAt8L`a&`tGoFW8M zJm-XBrbOkJaRh4tl2WAxrg09Yrn(ZT3%h5rN-Nxao(t}jOWLGici*oQqoG-i19|`x z6JuIfghDV)HT^|4A)#QBo}!*}#wm`PDIF$@W#l)q5rL~P zw^@xxcu|N1FQRROwpB!U$L{*#4dN!Sif;5}W#5{;f+834YU!b;PuruSp300Vu01(I zy{KhKtJKO(%gFLAflb4@W&5Jh`V{wJZUA|Z(lf3FEMjI4Qv=fpW0k;XRw%jRedM}T zPKV4Bt`E=fDv0K>P{;=_!}gga{QVC^N_O6cn_WRkRmTcqW?fKh$PzDh5sea>@1y`M zc31eFm*$CRMlOL~aCJFKD}*K(JD8qDUJ>&9EsQFlX$P)_Z;ma&6Oq3^zE#3W5&IjHwb02 zE{K;5-lCwa_#4j=kqlf$Ixu+B%ACrL$BA@D3Fh++%?Yy51jvX>hR7mNxCiKQcsPxb z?n&#NSr$qxL^XaH5!01g?NOrcCSK^tG&z+&;oM2+aw2@Sq_1#GV;$y%B*Hs_Ga?=I z%=@9u$^r$dZR?EI2AdTRTNqP^)r3V!Lv_bF$&y#`=mY=IigXH^lMj1T2K)iOQ%~O- zU-rIcvLVSg^-&?IRPA-O&`L1b!Nqon8Bj2OKggV1CFkf2me|NKotUeM$<8*Xh*xol zkb(n)gLONSzUZD5H{X>Yi$I6WnQ&CBQdU;0+T1*5^2ZaYkhF9-P8PAY z;ELqs6_D@C-|Q*+NW(DpEWTk%PVJ%ivm!VJJiMZ1ymf|xCs$B>L|;Zc5E;ao)Vg7L z*Tb3cf=y$s#k8H7N|X{X zjl&g`iW9y3{c13JFgH7aW$~?xR}V|ME@`r7pJhiRgW@GZA7@$xz||`Bi|A4LW=~O6 z4G#xbckmylPtYHALMlD3Z$-FhW!Aj*dfA-5xvWL%3rz8)Yq@k`+a!qBV)fjL&sO(h z3pL0DHo2%5=#N=QgEOL!SvZ8Z8=7)bs|Mv{)NYw|BBp;5zJjt8SZ`qSLo0vdo9#*UD^y`+kY8i;Lh$j1pqUp2i-e0auZ`?6u1hRk? z97MX8aoMfB8*wffAMaQA(ROA?72uUQnL%yIw5j-!`a@ze5`lD9!Ulnl1V(X~qZyVXh*k>P&!E5|( zkG*l5v)ttt7cTnL)9t%RedxC6>#$c)AS_T;K^3-VdZy3C!{O0jeK*@SDZN3dNm|)q zql+1S+3J%_2V-(|%7>%qYo@+Vxi3?RL#6~T=>F^~B4iu4GO)I-2p08*2>^~283_op~K z?oDi!j(iXkx$D7B4TUR5dr$xftk6U#{G*v;#pHVC;o%6wEdtr%`)o>~CYpB*&KSC3 zPwf2<{o^50Nl7`O7V!|ZMV^Jj5akj0ptdtHsAXZNvW z9u*bb;XJ?n>C(cjEj47Fpus&tyydPopR81%DexdTu@(H7E+Lb zl>)W7SoWecS<~3%yftEgkN(m0t!G|bwInZG1SS`^o=`t|dM<8FsXY4fP4D!BMHN}b zEIgORw?-~gkX$uHX~~L1p<{CF4y70RT}=C^rTes7P6|;Ns)NJQS5P~m47Mv8c&&Ek zsv4?^dmf>-$-y3Pie^9LWMsZv%FPWk2zqGM0iLD6f=stUPMi!-qa2|<_~ibOxm7F5 zg-j*bB4K7U!kcokZjXt(*7;S6PKv7qeGtjuc0K>2w@c4Dk&LtFTq{vKSy#18G zqAvhhrV}n(5QtjWLkFX5x5AWdiTUMrxDhxRmsu29_Kf)hU&hHxf^dS+eF@$4i7+uz z?qHV=jvg=-2+Dkm$!9;y?5#Y?As|l5ECNSmOA9$_TAPrALMe3alJIR$E@>uk)69AA9Cm@}D_(nGuluP|_ z=ivxOwD_b1&|03U&tkK*pzypcX5S?}72B#knF43JicTS+K(?6A=3+M|mt#MGmtMZx zGd(Ys?hut9*M572Pz;<~`jI;RF}YPsZPnXyGTTWCwJ$VVajS2S>`RS?M78EfZA-Fu zKCG@-@S&b^hc_b{f0M`+Mxt5}6{lnixb4z$`^7?PTa)Yk0oEhP-Vvfv2E&n$460>J zR?Nea%%zRb9VWbrV=^v>TC-BFpiq=DPRdwxj!dhS$3%*j6&`jpUCejuU$!sFIhPA# z9E}@-CGAo3^|v4qj=^gYG0D$^s0~=)pr#=D^q!rwhrdmbe{1@o=986GA9;&lCs`%v zw(w58V2&Et*S9UR9S8_Pa#AJH zQI?WD-JKFN2+^0!OiKGz2IWz5bsli6_NLQEvUs=*ni|;Xi#fHhVeExY5~zi$&q9G? zUe23MoGmIS(l0;MIW+r|e(=Toz__PTdDJtgCoA*z+0o_6b_5=ej?R+z)9+xTVMAsi zrKvH0kSj}0U2~XAd^#eSZ!^JxQN16D=aql5dF^Q#0axj>iu!v_@JE0C zSnv^dSkE8b6<+Ud=0pj!V~d?lPM1}hkTXz*qE?B5LtCIeB!mR%v!3@>H?;) zL`d{eoTg9Rrk^w!wK39=Auu26Vwlt~#c!Hv$(Vu9Y*0qdHcnJyGEMlvW$p8#qVMC9 zh(}tmaOjtI;((2Nl8_P$0#-U;f(3Y#enMk84-ZktgW=t}h;Gt_homK2+36orhcOJc z=ryUB`xqxlT#HyN!dk~PINbNRJDT^f3_!UIY4|QdQ0U>GtxN9v44R*Yey=OkB9qTsGM_iDU1?D&kt#w7|x4+YJ6 z7dqB$BEyCP8+nyp31rCg&)H14bQ_<9jVY@3$f&@XOR$lBdV2B2Sw%?~A9~*w)wN7p z3`F8#0!S#kNVhTOV9E@piN6i+)^4)}M?_5d2^|u%RH}4pw@=1&3oU&7%|wa4vo)5> zIj)>-23qa3feU)jZT%n;nHTB^h6~4EC zZss~ag;{&LnUnCoyEz!ekvKTKfpEyRyu4Pt)VG%@fcE8GObV1XiWI%v2%GLnDFFXs zoIA`RIYP`HCKRrQ`$jrbFcGU>lCE(X&CRF8J)l<1{?(yLN#W?=r=T~bgI7@D)zR3l zAF>hy(=uPp(yJSc+aOp)#H_j%M1ayIMh7R`1eKY`QEm1eh^r?ojWdC7zPvwen^kRp z>U-9kUi}i0{aH+g_8lt;T8@t8>6mT@s$De<+55V1^LZxY7GYN7jmw1JSetenyVFUZ znt-*8I7&EJZq3KJQ*zrSenxhCa7YpTJ;}^6@oMm{FHyiO-LWCnU5ZtJxm+;oaT z{8?ee&2~~3;kB_B*`_#oKw%T?v1j(y&%Da?v;nT6P*44en`$_AHUU>qo))2>CEYMF zBHQ`IK5}&iMwjeMUP09qW{28YClPRrVV{W|rT|=1L;2xdO_o76U$T^jN;$_FS=2hW9Zd~3trlxaqf9ty}~utms49*NQq`ke@r_AqR>L(+6O=>|gi2W@fnL+%$PEYWpG;>?L?tkfQnypKq0Mnw$ksGiLfgjPB9}fI zO<1Jj_I;N@+cIY*DPmMWWH_pTjr{4>);8E8kDyJ$tpUU_Qkw|kmrb~-7t#(u( zP4($X3sbFQEBeOL3tWNr<4d~it<0>f@PrTHrKjg$S*VGydu)xb)u0sm^=(lESBQjy z>wNW2?}3{RgddQpAFmnhL<=_19VJ$Wwc0dS26e&1^ z2oOA2d;99<|9SBUySvkA}cCIv@wV z-@yQeMC#<2S|vp*#8?Q*3m9X9Mop2_R+nz)5|{?zJa!y^(V}JDU(X*jprT{2(YE_m z68FM^?zI=TunYW|@g2cuODdAHVUAuSP7?}Q^S-<3Gb?kgZ=nwvQ-bDTux-?bU+V z=&=+_T2uwDM4_28y4C->usK{J4{XEqtPUowEQ1FsT47@u9M=Vp$))R{_8+cm3KU($;Q zNf2iMfDe)TCD5%n&ej_ru|-GlMXS434|J4Ir*%tHdTHz|X>M)M?G5MXs;%7 z?pQizK0v2!Y_d6ojYJAeBzSvPvJ3>KzGcmbURfh(jOLU@zJhuhTL4bperSrB9`>e= zUEI$9)ZBr7+zq`;CP_Sxm2=O$e9vRwdFdx!pz#wg&}3NX{Vbw)PpH(Ys?ADtHkiu7 zd;wQKo;iXQ8!U@dYG7MD!4=6`S|Q(8w2_@xGTd#F8$O-PgcXw_SVTlg^kE>0A5lXR zrZ%0QWWGzptm6VdM+QR1!CI#@sUPdwRAlSr(p`dADrGHsi2?l9XVw{J{v$MJTs5vB5V z&%VmIfLfaKLW1xjR#aDtcL%`$!+{A`OZ;k~D=6qj5z9X1m2Gn#;9if!YCHHtBA$OX z;mdcNNnH75gDYmiE73YWCATk9ZC0Y#u)4U+sr3nP=3hU)>jkZ>Ix;M#fM^ZqrY?f2 z^ynxtQNS3_j!S9`$1i=GOiUq*F%EhuHA^uv4Q$kWb@yOjf=zrPuuWE2Zr0Ss=%Z5h z^%ZD?{&ccD)Q91r*Q;R1FO5a(`|}8YED?i7s?L5x=q)xgf}{Odz%z&zFu&^fyw?PE zkDhY1l4W^*xG{5jQb#P98alm3AJ=>(%j$DB_LxD~3!p&zWVVSFAX4>-O;i+f!^m=! z;ozsz^~O)-oGk;(UsQ(CUB~aYTerllX42;?uu70_-IVhnFTdP1;m`%VFH<> z!-p5s)|a7jjo_Ln(<}vB=Tsjzq}of0ZE%x-PWpxZQVQHjObVgTn2inbUV$E@AOS7; z9 zV#rBWN5_mYew->)=6LJI-5R3Tm%=jhyjcT1qrcG1B9Ro16;A9x_fUx5A9IZFTb~Zy z-w62K!QBOggxaMo(0A>e$rO5S)jt8BIW)Y(>h{bLWhX#uBg(r66NySB!9}!Z2=5~E zbSt1|;D)!Ex~ z^Lpjf$4HrTjQxuPwW+*f#E2m^;b%RBX|SuW-=vGg`3Q=M%n!H~fU8I384iw=m%Gea zMk(kG33ffbEwf~8&_2>QJvF@)l}jab={bp6e}Sc}K6C5f#ctDGvCG2ge<{E~hyom= zr4?qFlf#l9rS~*GsjM|+!@e^G;85R=)5#~yJoL(nucYDS@)wS7V4;S^gvDC6dzX|9 z=SQ1;PVcDZk{x73TKA}5}-OpkM!+25yzMkUA&X3NH0781s5(L@K_{rA3DaH zAr@5*ark0W%YL}0QJ^NRVlXrW!*O7!ZFZ-Hj_#dCrL$pH7Nx(ybo-$AY#=X5SZ7B_ zVn7N+12vunymqe^Ic@F{K;Neq;D;%ko)%ivY8+v(au|{$V`)2i9Ul1#;b>ywu8M|) zi5~iw5)|ZRzn8wu7;LU9{)#;dAkHT5H+R1xMx5{o>jS z3H*=zo3E96eAi7)JPb2UfY|eikrL2o)jo|j=?u=ce3NwPd2LQ}__7;1pT)Jry^JD` zc$)1kD~h z%?x-|RD4NZer(MUApPjTU`o;8&0%+iQ{U2<@78GfIPgNOf2c0*Y*xA{+Cn+i%&jiBtXAReB*6Eu_(EKEx8##BF z1kPrLdA8dav2mPAfj}no5XlWmf4^a(T<;k?V`g_8shw*9nvmwMdZV!$OVTAffRS^0Fx!8w?GofXrn@ zS9x4g=r`jp9o$nS`1;$W{4774PDFq>v)(k!P?xD02dx4fMi_FkcX`7Ea*;^6YD-P? zrE6hFrJFDp``9!g4n7!Cq&=*j%IatiS4o3hZ1db@=jm)Df3IV`r%_*OTQQ=_6&tD& zEYY0(cMjJkDERImbq z^e(*!2!vjx_f9}Sq_;pox+q2IU1@>P2|aWIK?s8M-fIX&dIzaW^+vzF&-wN~`|f?p zy<^-Tmp>U0us`L4CzwdQ>0Tm~1pU7qou{8zlU`jI`WRpy|QN47Bdtzv?8#u0C2 z+b@%&6I%TE6(@yCZR~YcW0u~$3ua`GqZPO7`Sy(PS3c9N7Ljl6pHZi(su7iLYVI1Xt zyGdU#Qr@v)VnXvFgxf{<%RR^V$BnJt`C%+KqF~8oE%`yLt z-Glub;fp%ogzgoqjV6YSZj7apIIs5=90*nXK_E5z(b}LDLow0@c&FGPzBuI#qV$Yvfe>O;v z(pX3a91aczsA|#pGNtppalu5#wK;J9s6!7+Hoh}vCSPQ>vomaPt!gKkqc)0Rj?aJd zw${ViZECo1PomT-E?vdMSFk8P-91DItMu;7rOw2XUDUK0z8rP*)q?lk-rDAppS@D7 zM`$U=^<5V040l`19p{ep_i3!J*=_;Ng%mlmK~IRu{WsWr)?b#Gx{}rA^K7wryBb}6 z)C}h@FpLh<9h+PAcSgzT+yo!zBmhY6kTWv+%|K7lXiV<2&;{>Fg3OW<*<(l!rs3{^ zPrqd{ol97_q<*(>nD6FxP$$HR!uMgz$s?$$NBxivP_Iv*{poJx!@J{gSqIuQ9`p18 zzE;C_BENC8ELsC<^``G5U+6v7qPmu+jR?GFl4lB<34~0#J6w*}h4rc{xy(N2KxE|4 zZ+t#xcd=}})vf=&IQD!t#a@5d${6)+zHmQT)61q?NKvDJyC!{I$?m*C25`^!Zt%%H z==&dnfjP8u&zFj>@2p!rXPLZ-_i%iJWQIHJe~wmE)qS=)X@SAfzFIVevJy;%W!0pf zjcU`VNSsrECG#A!vPA~lsG%?YI`)GKr!9&=AN;y}=in?92hn|Nu*QN7goX^aCN$w7+R-_&t_o^YhMl0q)!fCmYvjcYsQmWUbZhm z?G%4CB1{TD+f_Cg>9>vW+x%$fj=_`6T(_G!Xb~ZMdwfwo(4NdsL)@xJN4tliax~8K z`*}2+Q?u|vfcoOehbvZoKeoPyjmBD3-#^<%z3_{`XAou!xa8Q}VTsE+*Je2QK3lsh zb6|N;?~i)bRnTm*RSE-_YT)k^URPna>8nQsNI3t^%SqL|n~MF~U2k!aqkF%Rx3Ap_ zi#xBSDrd+~vDWjkSZp+Jr*|PX*>-G2OcQ9tsm}cg!*l}hPXE`VmIkMxC0Q5Rl;fKO z?V;A}?#gc*B4-lrOv$QI{ch%;mXwjc=!POqRSvrC8WGS-q!*jNea21K{`>tnV~v=B zjEKl%cb{n=pLLTZ68kK4fug8)x9sub)>ceT%Ee}?_$FB6%3?o5i*}|Zx$oqZ+yF+<- z^}4O9+Vo=_xAJZ+SOQbk&`&4G=x`GU^;lCuIaZH}eCAW@7NHS-$wn87gSm~mkXS`J z1&@y3IEP9a8s*dPl!qS9I&2VL@+03pKSQ=3yLx?p=Xt0(R6D{QRmq>%|ZaC|49;0M!XV?_Q}& z@NE$yU@5Ds9sV>y#}B=&r51Ko6aUZ-`{xXW0$;fZ-O^Uqr0L}@Jm-skmieP2NG#u? zCj=*?$8T*u#j|TnBfcI^6^vC$N2Y77e(6 z`y5<>)SRbvp5!~n{E3cqQ5L3p?5WdM%cOQDCN47;^N#4Z3dUjMkA!M!&dl~4y%@IA z%6|Uu9fW%uUP431We~#PwVaHRGF}~)04`-3+ugVgmdcDsEc|WSc zIv?C|x zNdTiIv0wZ#Z$5)kjCsbI2Y^+dZGYqNIo=+lwY0u+vZOc`W8$pVB!zmHWWddSL0DO( zNorLEQ3ODN@?3)DCP$9XKQ7}Ja!(lAFg%Cr-(0U(xqyCvrDNmey&6!>=s#t{Jjh&$@J+n5>;H36c>HGH05L1#i@VE=5dv4 z3tkp`xPv~ z$@wO>dh2CfS`C6Z{tGdo)o}BEQDp;g5D4UVR2A%4SGYVF9r;!Jy3~W86-Cd+G^@J%JFHZ;dlEz1J$A7^>jtaM5}&XKp>M_ zaiE~MRzZI2iLx#LfiRpT46!^5dC&T570xRnUIKy_Nr|y0pG0J*{e0x2*ACzV>hgj|ag4Tjsm`_7t{nVZlVoy1M$Zy7;pA z0$H{!E<6?ODYE9E41$%qDO=o6)w#ORFT#h|l;ckVKRxe^W|k~ayWEt^s1VN49&aj* zdKM)#kLDjx1|u{z3o0(MAc*K|BRO#3LwD@cwMt-OIO&yuFh?n0RJ99M{KiQ#sCrp8 zDbyvmQe>1w>~tNRuTXyaq&Y+v*vZ(eb$&lxVwbl;=FH3dZ2DF9rFi8f=+Ic>-J1!c z33Xzdz5IL3iPG8wQE|pfJ1?mCS`GKtZ_gL6qFuX%<)-<{>eS~!@lA&}c;v1(kx|@6 zAcjYxZ8wgB`ti#ciC^MhRs;DY@|>KLMI138KBYtD>{)k&lPjR`;R-eA{;(PsCEclT zV42IM`})`d7y$Ajl5XD#`EY3=ycb;@w8x3w5wPP+eYJ$Wf;Z>|IF4NP({*{x+Df(k z`XI>-2GmPqB`!K-@4ws|ehu_b7`KUg$kh`?Wn0RlXY}L(M*Zz3z{bG6Ps9YLH|=4) z4+O>XD-%LKk)Nvi9ce=VS4oVQ$)&aZC%bR{C&w&l8m3;!* zkS8T-xH3XzoFfoE0bN1UE~P~YVFWyCSukRL0s#-d2v2ph#pYVMxPFrBH$ktkQD9|l zvh7pJ5s9lgfiQ*+W;^?INn)%;HiS5$v}O5yc=-&FZ_!wcqh zeSJp<&^YcpV|z&YTH4O^Z=C9u>vJz`Xl!Qj=BmPb&a}PlLruZ5zvx0G%$r?HJs@n%XJ&+uAkTV&L)1^eBgcEkKbW~rX-#A`oE^n=d zPU=o>Pm0wk9Bp;dZ;GkeV9WxdvgF%+;}viZhLcTs7l^&Eh02<+FVHWQ$Y@0oiBWx8 z9#RuqwrJX?t`#oY*cX(ikLz>I3!-Db@UZd|B6L)`xo+uS!lc5m7oAg|*=ipdHRZ zjjoI)RRGiOHNo=_kBy*_SFAFK@Oi3nljqao_qCKi!wTRK9A3uO-%*?6&{qOTeyIi8KW;FUIxMq? z58okBsk2p9m-J_etv{&Q#&4?0TQN$fc_7HAwV`n^FZ*1Z{t|&*R%%~I^|T?0Gr1Iqfgk8VHq6oMkr(OQE&-gj(Vjw`af>F!t9qbz zu3D%@E$U)(p#WQFkK2S;y^?YjS8AwnoAfWpXRj&pH{Ma-16f^f^%wEEgl5E_#*-3@4r4n&Y(J5Fq7FC{)+xQ(}d*^O;-9d7y7%hM`#YR-DJ)QUdd7V(*OBXTr;?(t|Q(uhA zsy~yz^~;16*>G#@YkgW@P(bh)^6`TNVpVBx;OjYM%S*A;=uqW7-6B|xo+G}mep z;5|FSl%d`V!9+6uGSwKay^S8Rp8T9~kt0 z27Zzr1N_Dj7VeXEA=jw9}2#0oPH=Wn73^ZD*MGC}y5j#rGJ|96U z@w8z_y0^p1Xi!#S^)Pu+?r z_KOnpW19{t;*2#1R@s@E{N^I+=I70tzKU$h546!!*N;-SO#(eSafz@+4~=cvaBfji z%|%2TLqC78x_m$^!51<2)W;`a+;a|Xkxl+`8N&QD0Z{ot<-R%l6MG(e+bBmzeqe&{ zdM!|yRM$w(&PEr-7ZGWC5(APA?c|e*(yBnJ#rkr9@`Kf_H54!O7ucpZcOi&R zTA4+lsEV_^ds-S-Je30Ho_9vFGZ!BI**xrbVuG_$1lm0Tu&+r1nr z_i5BPHKES=qb2mhBCsmmc#^I!PE#+d;{HYiYU$|~pp3-r4F!#D?*c-4JDtY}8=p?4 z3|wC~`B5MV?UtiGHyFIyGJQ(%{Zsis)ADS@>EL-={9+TDwqVo6w)BC%YO3C2=}APO zuu>nt|I+l8Z_0nQKnpv|gr#vs7dsgk&^UHt$xFcW#>8tXGR=hpV`JuK-+oSs$GF~j z^~nHrQ(aR>X3LViqy;4l|Ac6DkVX!?Fg`!*ZP?2}+Dc`ey{nQ&3!h8euP*=QSwKqK>z>%_+NFph(C>WbdjdJ_=Gq%t>0^2J zdEdDlYteC+Z?Z408=&poeVvo>u^z9em;ss}-fZhY!=e3AeK`-+M7s-qWa4Vy*89?V zCwQDaIP)(1mTYq?*iKg#*Tuch3g=G6s=_%y0P)h=awslINslf#wbA#{A-k8Uds(7> zQGQW^uX+=4Tw-IRIoD5nM(Y~MAoLwKNiAy&nVca{eH`I8M^Qy%R*)iUec4fX%k z82-n`M>-h)2k5#`%eJUTH=yY&8U9gsfu?DHa9ekPm$BvPQ-{7{8-Sv?mQ!+VznUH!mGLfj7@ z-&w%yF2`{FP))#L{uT4I7l9zoXeYiktp(;bHZ(8LnnZep!T}0-^(fbC7=LU1g-AR= zJWUX=!g{3H{LpBuNBZTV0r4Jm@AUcCjCh$c*q1^2Sm3tSd)`aAd91ZlG%#2F<(_(u zWBLqYV?K2p3_NU05ZghN?>pC#aA=66kSk2uw!UlW6q6q5S8aOHIrvX+Q~nc5`(UFz zJr5pfx#-ii&BCvm^!f`R+U{?%srN3nWCT*5Pdjj^6~rz(3T6%lbDx!6pH6GCuO%*- zd5#4bOOyTM3sBw?CHv}R;4wfse+b`q4QY4a!HjCBPgaEF76jfBmSuj&@NdV~gylt~ zKw(p7lJBk%HWVV0>fvHb5Ab$*UxZXw%!Bkp0Ws;jL-I|V5t$*12p@ktJKJ|(`6b$g zW!xMnN?-p=zn?Sg4Q!<3B&sC|B<8^x-(t(j$+NDP-Jtqs;^Bib7q+0^&YHrmBqTY2YvI!cbM zm7+*gD^XRw!0IS{vJisnzRwb-W!dE#?+dFGwNsuHF6OA&t!2Y*-4Fw6*Lv9J`!wnO z633Uu2IFna&j}$t{q&y;0{yDZ-Dnrvf7&TGX>CMx8AFgvr{T3mbJtp%>_z{65 z6dQIMl|<6RoUw$rXEyVAnEvqqY$%j*snnnNm=c?$f*rOdu1Q_2!v<|f(LPU*wI8mY zY_e3~^huPH3dz`|3d3IKiT@{q5Oi>?oFN?Lm*;ciuVt}dIShT*EP{$rhU#jbNpKlzQUTgK+NEU7b1Vz+JsMjVB|o8*RRW}$cs%zs75 z96U4H{lfLVe&m}ko=(J{jIGYk#7JX+?La|Ae*8*SnnfBjI9_y7#r%i2u9RZouW<*$ z;?ee7cNoB-3x-pAU!@9v={t}Yj>+Q$W9vlE8chMcPTeJw$@DXh(tj#5I+EP*UgLoa z4-`Gq9QWOU5A>9YgKsI^N^D4VT7OjHbHnubvag*kD`Dx*h^mg>@tj*27;x7_9GX43 z-aF(hWO&{i1&h-2zU?2&7x4XN;`wXfRPXy-$i39S+u^EW%D!2-H)RHih!#qwI7u;! zEbD!{O5qsO8F<4+ht@cIST z@-&!QRLRq@C%2il6hr%T{Zfrn^bW2R7=9)<4_g;h7JcB#f|YE4yEWKa6QisT7_6IL zgngNPo-}z%M5k_`cbs$Ui&&fXJ+r;!i%~oZBOHJG-#YiU1+$zb4r-5FdC;MxG!kL$&vuUu&49gPM|?I` zaO};k{*m5V9$;lQHjWjrHZ}{AA-6m!cT&9(P+CaPOfLG3gK0mk$gk%NUNKXp*T2`* zm>^mBIM$1`L4$#MTircXT4(~Y#^>i$!rW)^Yg$h^sTC{!>EDa9`2G=BIr<|W|Gy*7 z^H;m+U+w<@x1^a{u;8 z{x8VzKOhSp|1p8uUyCLEwHU`=6X^Uk!T+Lo;a}~Vf3>Io&uIUH{eJ}W*J4S3E%p!W zI!AxH`ftI={=v@i7rQR@X(vg-um*)Rf6@L&-1@J0_h0c(|6ZKuuiZ`m+P(U(-TzbZf7brb;+X#$%s-3&-+=kAw1=;l zoV5s!`l-L32@Lsg6K*C{6WEG4;Z4IBVt!;d1e`s%6T-WYeJFc(n#nK3)E3Lp>(Ss5 z81FDY;F>RiwyY&Gc`w*>dbY^YAba$cj(j6dq#lV4Rg`1SYpO(+N~+3W2wifs24&#s zvH35MxXKseXx`*+K2WHW=1s|O9KRbc6wjnfw5KU-o_w=jv5{x=fBg%)l=Kd3z1GEh zg2n$6HUB$5{Xg|TKok%9Y73EmZKPcSxfJ+~)1f=o+a6cY-xQ7ZDGj81In#AdHOz-b`u`LIJ3?7PAygL~@2L<&qH+Aa0~)yZh5{i~6?P&;XH zyM~0xXU5L_mnqm8x-0z=AhW!)P7Mhh6edfyon6$pUP-n$isrzyA`-6mpYtq5PHRVO ze|l>SQGQ#=vq5eqG}ID9Zl+(c66&Q^|A}l;d`ObQVEg<9Z^`7*2Q&TBHJrv0VBak- zz0#zh?E-C1yh)QdgGcZ>+F}Zz{o0Ghav3hXNymm)PVjN}FVn!aHMfe={(fdNqZEiz zW94)1Sg+Twji|Sqnks}QeSQB`tniPRX5WgLVR~{`W4Xw)STBBY=#b|`Zs8*r# z2%h{ZhXTme@PZc(bpK#x0LONv$OzjNt;(d^+nMGZc$4DISTQfZ914RSF>H^g+`cy! z8Dn*1+S5OR1DoDflo%c^Hx`(7M{Os&(m(t6O+K{G;6ih6DvGDw+8XoBx*u-G{%yMY z({zY!GXK*Q_($shm8Spxi~j+L|0kRNNAzS@4i&{lhp@)nH!F%>N=sc)+oA2TWRs6R zQ9pxI&4;?&-%$XY$j*RDbLqvp8Uq?G%aQB zw_OnMKCZFjq|0vlGa+?01YBp&EbR;i7Br6b zt1?QzSVwoW<6!uo|M-}X)a?fXknf&i3@f~Lp!fubWg1LwhO)EwH_oWX;C5r$q&CJkHscEOAPZShxb@6= z{UXD+er>R6N3K77a|!DpbvRiJl#Ulwa3wmc^|U^v%CzUPl=wM6_toBO+_3kT(>n@H zW57XrMVh|z@w#fB^nAw=XWM3>(I=-8UfXwob#G+&p7%mq$X^{hmIUo+L#%XY_$6H5C$<{X zj;M$aJpn}5XA-)%WJ=MsalkF&YeWmk`LWy`@plf*d4e-QcL$PaaWwy1fRvqs*du%GvBg!|`!c^hD= z@V0=oiMFD|W)EhlVRi*~V(if_jcwM^T}~a{c^H*(y%^O|0(-y=h0}Y>X(>6s^B0$7 zIA~G-gijtV)i?LZHb}b1QT(kh{3{N%=5K!Zj)vGL0&E5__*tTJF_A}hbUr?^K3!y= zWYtWTG;b~prtt9`IOHuC$icdp9hLXD`?IQ9JthXO0po32qxJWZsvyCUZc-~WU$9M2 z;&s0O=|J7v`PFMd6S9HTKA2UF z(~1Gl5&8}~q=0MX!T~3)ji#bY#QNt&^X+J#-l+~BiQ^sO<}WX_XBq174k(7D2yUIU z-r3RS^MRs1Xv#alq9DX*3$S~@dE!pu0c={aB0D`{>#5XF=M;_aGzV#0+om#AMw)^l zmjnG~Yk}vG)!#VKsio_oW|zg(l)><0im!e%erpHj!VF^Xrw7A_UY{OY9;f{OjsL$v z{=YC7bgw!O%tc%W)Zg4}Xw4?x*qaPL3mj-vU8Os-H)Bcym=RA=g)Mj)#A>rBU5X};e~gPduiScqL(7~50PQBVI*Lr`9UOV zQGcj_IHkE^EHYa*Tg;9pUw+Z+F5x%NF~e7&PkIS&P1TH7{p0WL0la$^13I* zYmp}&n7A*6)AAMJg|Zc)`1x2b$j6o+lZPT&gqYibFNweJ`rgqq%bm{of}FDSw`Akp zivErB{bFm=1!sBp{3CKo_M&Z@k&|L?=M{KpEaisj4+qb79^kL{8%KF}8d4T`m8lp% z(J~#dPQK)Q)A6Qb_ck+(Nqk^nc3_~#fdk>!FUI0uW{qJt$Y%d?_YS-ao~W1QfE-u3 zbb%uAP%v$v&XQ?B$tNy&H`oDluJbc^EK<6W$J1nMmVfKL&E+FCrOA93w6pNBfB)*m z?v5J6TsFEcu$8Y*d>#f4mru|6(>XJ;#x3%gk5IiyrNys>@s~LP$3Nw(ww>?9qJwI9 z(zwGu@=(dn~a5(g5bOGc;ysc54fi z3WJc0@bTDF*m&N3G5K`1oTcDq-_X?n`Dj<+NtbTVUD>DPJ(SXskFLx1Qfv*E({iT5 zwIJcr=(VI8y9DU_bAO6hR4s6Twxi;eLC~%vgi`84(kV2 zx6d80$orT}c)3=ic$T=+$X>!m{gBOfJEksNLFmM6B87S;vT8meZ%D*B%zUl2+1HVp zn8=d&AG}tSMkIaXQ~bHiBWH*$1g<$QJ^Ve}Oy-<3{4l_JT)#zoK4tQfW=~tu0%>40 zJpX0&8#V`AGQ_ZA#H6RzvyL3T^%L2^p8FL8=Q)+H7-V)L5pllnVFlhW3E7`56;U%c z{(99-c-Hd$H_jh>)UNac?OZ$4YeUq90~Q>FMh@7g2(pFAMd@4=(QGFAVrqCr&cpL_ z!gYe%HPDF%5f=s@c=z`&2FhQWV5n4mI{RS0dkpQ1a}LE` zMfV8zu!Fm0*XvodLD~|cDK@4{ybw_8LdENwqFb0KuM2(rV}dP{rx}d8 zGg)gF9^UegHAv^ie0Y%ZJ(jj0olim(<%6cbEYiY0gZfeD8s79hFP(P8=Zk?{rUMk) z8xdPb*=x^zyZs`z8IKcz!Y614G$A-0byB&2EjL+XLpYSjIQ#p38Ak`Yo2+e zjBmz)vw6oS#0yH)_I&tPTtV(#aHgtpSsRr)DFLgjzhnMZ>|(0w3I%#!rvF@FkILv~Rs_w$?jBb20lH44K zFf3)4lvg>OlqD?J(WJaXveO{O!Rs0JGo58L`gl*mZWLATGtiqX(pS{)zx|H-VNaI{ zh@%Da-pH?RzSr=o$S-tZ`iQD$hR<7IreNshVP5|1K8ltGRK~>6$DG>xjP>b z$5QkS_w1dS7DO>RayjNtzYT7d9F1$WPsv0z_G0uy0@qpCquo3kwItNv0vwvJ%F>vd z%rtHqRB4|0w7+d`7CXIp_tfn-&Lx|ok*@1?kt;Y2&YKy&$B!mm*4W*>*!_+3?LH$N zGf$Yd;Nhb?&L|QYVH*FMI8b9rw34VL1%UmM>a&5eMbXEIm#HAoatJmOAY!J9Jo-hU z2X1P{n@D$k{?UsK)BpXS8>6v}f-?!NQc@Dlua2nH3H|st0rz9lo7?tl7`+3Ik*@|m z+|O>}N!wcCUjo$gZ#$=gHr|A0vnOQT{_M9K6}RVF0xPCVExH%eGX5ysm0fMo>62b2TqbZ#BpGu)$Qy>XQmL8@ z!@QnArnOwTA+#CQXrrbm2RYtmsMtD5Uu;RQV%IhpyP*bOBAHD3j^8g?Zh+)k&?f3g@wRnh8y zUzvSkM;317?Q$ofs6BN%?isMlXClo%&G=K3#-*Z6A6?v-wsG6cHpz_jS$0E0)Jb~G^<3iNbQ5={gu9|~CjPqQt z!Gv&Cyq5F_qHl%D*$y42zSZ`#Bk-rwfARrpJg_)7Xf)f%k0{_Wruyzdd=WQfvekKL zHjTZfHvyYLWO)>-XVg!3^uY37>DJJl(J4!>NWK+muuG+=axl{MB*13L6A(A*6kAj} zicW08FKmUpdZhX4gm7e%QjVI*bggC^LG@kzjY-AHDAblx&=uZ|poMWQ1{b%>)xb)= z$Df(7-RtSSX*IksEhv;I--k)h$qOkoIHzyzDZVPJUMk+KAX3a<)`Vl3?f(CA?ViLQ; znhBjR8!h_O3kG1Hq*rg9D%)LYrvH#1a090(@uUNsgqt14i*`XWiRn_y>b7Zjl7=(Q zRv`8%+%qxme#ws5n7OIT(82knYfS%+c(Zm0h`9jXIOb6@K~$Y&nJ#Ua4Yn>VKU>o` zHx^10NO%XO*Y{h0i3oexAIn43DFv)C;*T=Rt$q|8T`E6UPKxX^wWevuzk6io?wr`b zZT$YtG^`#^f+EKL2e)=iQ3s^dU&?b}Vx+h5$l)$R%;woy=4=*CY^geXMeZ0!dsxT7 z&K*;H!CBKL#gw4yjcfA9_2>#u;R_m5((!(Sje0h>o|jaUnhV2bt}XI7jv0a#V3Aa) zx~4vHr=Zdji`^{CA42T#4M{xsy}6aW{a_*yNu+Q)Vs*C$v<+EEN{pBILlD?O6}kIC zA)0kJJdHFl)a8w6DkFnOd)j)lU6-3L@In-8?7(4mhPR7qy4|jeSUz01i#j+wQ(T2P zL^Xb9e~Ui09N&A^OxO70NxP|r#jIx5$F)=%SMf$P9B)>Qy0>IeDh12=$Ud~yG@@U4 z5Tv_!Z_GA$=ZCGC9UrP9}b0auU0h*g9;MrC2no@gdO# z1dO9M7mi$WjUFjx&|2dufpeZtVA2l)zam@#FTog~l->fkEo@I7+p0K@)fi{Qjv1c?t z+aQEL&LQZ9lR`(EX1X17x_BXwAzb)V|6~Ayn{yQ;Gv*Ri`HcZy^rL9&rA!x=As<4| zRaTFi0-}}M!_)SnQO4C8nCl;+{%U~t>~$V_LDCt$?mnH_!CS2m-U$k|loESKg@-)S zqmHF}ud!U!XHKyj914i*l}A+ybU*c%O9^bIE>_@aZxRH0LJN7=Df(^W&E152q^3z3 zNqS-5W*Msn1}eUUSn}uO=T1buUR{-5Qq^}%eXXu2NV;x$wHb?bJ;G~rugYbGSpBPm zcUG0?syR>zhT4g{G%9Wu{dYR`J>YyJ510#Qk8sx*uIU{wdlrc!wCTc(n?>mFs?JqKklzI$JW1OgH@=!xj$Ld`!B&W=F14TG z+*t)5#@u_ZUIzB*H`{6$F>g8!I50gflh9a+S5=v0UmPpBK5ITpptlJvAFD4=i4_m% zE<3WF9s$*N*<#Do#au;F>~I^m;}OsiU`Z(?G`Ma`;tDp`g?6%v5zeQOz90hrD7Z*W z#{1o&im|0MlZPJ~B_&VDEY{Lxv*L{3Br7qMbox9rCiF?5VIN6_paS7 z>k|-`rjO1dA+TZvx>tkPO~ZMQ>x9e1WifQ#3#_g|?^AYPI_8cfJhPRBbhV&8)7#Q#I`)Re`4@yPo@BACaL#aOA^7IdP@&y(h|5@qPlB-08KB@Gfd{m*%4BP_VC6WJMPsYi*K~ucv-)W zs?+AGvvMaSlIuywVQkSBlAJ+-4zCZ%Zbqgb{#u*;=2Du~ZGL~W)>g=ttOh0KEnU2q zR4xsHkCCVrAPTaY{V;a8r` zEG`#7@45|WwDHq^7318eyscDc#MjVG2+0(Lx2z&z=Q<}uMj3#%Ct?y!tT{ib{WZ{_ zH1l7vnS@hjtKY4bHO?i(?F{E+Z_uOTnP3`&I=~q5<~qhqN{BA3AxN$e70PmsE#Ij0 zM8EB=-6ZS`3@M0>cE1=vmkHcP-(NTaud~1G?>VK16)ybudHd`i^S1QA&D(Blnns9O z4Qxf5&Uq%{RNP%Mrgm%DuD2oiHxB2Qv?Q)5h&+fWzBY(I8q8Gw=}{;T9x}@#iseo7RF{#elmOy8u#g8VafJ-gg2DWXmW$#|p#HcqvuHbqkRLJzu!keqF-X-Dw z1SRv`T5KWidloLcv~^i9AojJ!i7ivYsm__WzN%QUU&2#Mlr$-%kIRp<(?Hy;QMK4R zn5IN)77Jnw@=bT8kZg`jzy2^gP-ciP9~S61$J=;Hc9(!No_to`$B@=1=sU<1JZl-H z<~Hlt_xNe*Z=73+PC2@T{w`32R^;m?@`06Lqfem}8vPF!sA@LgI24QcU$@6~YnF7D znmVLnoxPp2z^|28RQzujJ1}w=Hkrf1pO+2oFJvT3^2fPC+(kNq8J z%4O~e%8|=7xiN=}6O>2Al*GW#$g-?4G=x3gq2_;Fr%{We${Gtaf-uh)=#QUet|N2g z;3|Y%l!&d>3=Ctxui+~$bv009Bzruzidj-d_V_?6y;0EoweVWP+J>(7%gh$< zssi|uMOIvvF-LCfnrlYugZS+osw^(}1P{ASR6E|wUEN+1!gy}gxByw{mljREuZ2kZ zwZ@w~n)KJTcq)t=E5mljgx(nR2#FVBfc9IpjrRkz_JG#Pn)qX)Q}U_9#k~lU#&F6` z_x#A6r{bwk)2tjdOn10dgT>aibBhZhVWQjfgT)aS@^8Sak2SPQQ7FVZa_oGx&J@)@ z&$xuGT&xm3X{3EM_8X_H@JI$beJopeTPk*_>_e3^09pKh~GE6QdPiM*W(WQ{#Gx ztH=16fK50z|Ix?L%Bs0?T_N5~AwVx&&}1;3JZhVqYn9-TH`l7-#p09y;0xD<22HqY zrF(J6a-3S9zXH?P{%LJI&B(;M*8XoC6z^i&4PZGhoq-K<%QSBSsW1n=25Tw}?RK}16NBf2>CLEK5UH`gK7ES|UNo@@xLv=MFIVKa0L#Svyyu^)-1)=I5l_lUHB(jwoe~Gk5 z8!&KCFiCsop=7y<0=d#ioI~r;5b`c^Nqc3LiNol=j%D94Y^`gyUbJOkqB>odIC?L! zQ*eWr|2{syqy}qC!4>h~z3+LtW~}A zxO5vPxdFR$Rd@cz*}Uy%hmBMNk93{D_0qjB!QX4*zXIuxNH2TSt&Qm$B}ctwElLTQ zKu(N`g4&wJx9k9W7}e1pS16Sj$#rGrKv~20()!))v>p?xPx@4XAI#Mofp0&N7#^>6 zm7v$s6@%4(&^uE^-wy&=vV&Ky-p*)g1H;R9oXc~(^7G9kn1WyIGU@;(xNcFOqAk*2C;#L zI6HUDW0(`I(N%>hdlt4-n6BYT-Dm@`ZZAK0j_OtQVD_58N2Pz;%vPirbgx#hiU+tH7*gF%#K9`hiXY{6q*B)VgFhKI8NqyQe8Nb+L4Y~cH z;dJo{!N9<}N$KHt?(9D7e)IDHIKB)$ZLNV29kV{3!Y5`Lgm6DXJex>uW!S^;tEGf6 zvUsg9zZyx42+m}X@aUJUcyC6>Dj(rU`4FB^4wsDvt!4R&H&`~jIW3HNwzcqpJ#^K> zk|z^Uyb%lSk)^`=`v6D zr(BZhWxC*R3rtWvvBo;e>{DDB7o=89AIvPPp#`JZp#mdMLB?2F z-##yF|H`Oz+b|Ur%mQLpt3hwW8FWSH2L3Xi)z(C3k}It%Y|z$?)_>3j(~}C1SR3&F zpp|*tjR%d@EBN`y2dT1dOsvSE-Wk_c)C!E~AIP8EgqkeM8W=pzffv?!1^0w~X5$HZb5y~y&4miZ&n+^+oj!MWqtDwn2TaA^Lqj?qud24hM6c)#FSlh%pvnQxPws5h-5-|Wvk zPSz1nnZ-Wei0#~RS5(=Btqc=2%2d%xd5?s{;@6^Md)CKxIDy~#N2nCmz{SxQJXkw} zUU~F3QzZ}i1XT8QNN5+Tv8GK=AY`ooD_0#C$627nKG)*>4U8lBv+mD_+cc_|bx1>b z?c=dze6Z47<=|s?%n2y<*V^iq<D|HVWi_ z6v%eo$}p?b7;FtqvBQ%7A1!;X2w`c?^^-=ij_03szyE^crYdDt|4z42AQww?|1)4; z-X8&prI|ev@9P@b_VE$UFF1pJ&VI;`$qE>KaeDkL60=w0nMQ$JwExq7@E>hoz}n&l z{=b@H{$}>CO<__)f3y1Gw+{T#(nskZcaIBVM5v!Bo{?k z33UE%CD2W_%RgVVJqNB8HzM=5gEMVRNpn_Xq~&QnzgHJ3#Z$*`8^39KAw|{S>2eWj zD7z+1^2E>YAC#Z@sDjfX>S9Gml{(vynxKGmjvtda$Vtz+T4F8LZ5hsb=p37?x6=Th z9j=TmqfgFxL@)!8;g%*`Dx`yR2$4~NW@P=aZa+c?0yoJ;XU6Q_?<5>ZMnn!+&NCJl zN6?0`rR2k5B}DTc^OQ9JwR0$uvA9I%6tObetn<5Bvj{dv3a<%3ucQE2a*J>5m!*WpYxWNU^y4%+?b=nob959+yny_p0Z}e)za{mb^2>J zBrT$H3oHU0xlt|}hnLa*W&i=QBn(RItV1*Im^z%m-kl&@7fg>@$P(!%%t#eA4K($2 zo37mA)I4rj-5me(VMeMV5?lLX?^A9$DB7?Ao~NZh@CZxak+*R$w5Yr=%FSwBKx;P5 zz=*On=R{Hsi=GC~x~M)O5dC_G?onM!Fq=z*f?spKL1qsVl;2t|Kh(uNp&_Lup}vWG zt)Y9`@EO!OK%OPTknI-j&~9g(Vvj{4Z7j3>AY)F^Xp?84=8yU;jpTwoAir--ul!N| ziS*3K1h#qu?#l`9G%dW2__($B&g=;JE>0}sMd$bnI8p3o zwroe~p1$H6tC)f$9v5!Lp}u9y9Mcu{InbNicOhCJ^-Zvye)(=uL6%k3VhwkJ;o5tW z%oLj!Ov&eS&j73ElqR~&)>g0HZ~Is$yb_9waw+Na=n^+@jg8PSQVu$?njShbK_!mw zM(K#>2eV&+lzzU(TsG)O}cO%yg1h*6%u2H|>V4#%bR6B1?)11SI0!&fuSq~p0ETRgr z1NYKI%S}&eQwro?`F8kXPUQNUfSvEwmX0rhqWASurIva29CR9HCjE$ZCIw{vQu~em z<;MrMQQ_E>Csh*+)LM2YD4&~7-Q8m?hRF-Ni40`g6u}8zLgXFrm02`o6*@-jclPOW zUs>}vLpaaZ5d_@6y^h?>^7a@}n7snlP!Qsfha_fMyjH0jUqwC|l)wK}nG1N~h}WV% zXv(hhM&Qe2%TZMCq*gSJl6hG|#=|y7rX$s(_Mc|XZ}3Jb=Ug9U)B>+H#T@l6OOv1Z z{A>R7x>miqg_n`)J=S+*V8+X+od28#GOypy7>X?gZa zT7>>RvtANQ8U}V2#or+Dnzn}gIg4%#!9Nq#4%u~f6;iDzF_!h;=0+Yi!s;(4Xs4z| z-VkCPpPQ^^f=kqAGvn2#e!5Sj3uJSFSc5@6R_y)E@*Ngo$|})AdyETVX_)C$VLS0R zj4w-r9KYpK&l${HtT&Zr>OaF^w*NGmFZOiK7JyHlWsqALdAGSW*WezwISMQwP7}n4 zRu_#QAA5ava1b1}ti@myf^s-NP?q^jXm@k&$^+kgX^(X?8iO3GuZ+SVXDM`Z8g{^p z*5W#=`ERRv?q62({nBGkMIqKB@Qz6o^RnQ2z$Nq1(RN%p9#C%O67Vza$D26eUR&<2kxjxCdvz%-AGTar3jVfSMBW&|uxMi+ll^Un^wLJP-gO0Y^MsXj)_k}J z($6oV{9Gc+$m}iaK*dAu#N(!Zc$fc%PNBEye zOghyEidA0B7jspym)3sC&-h%>o;9Ei2SuAbY3L-p_|3;C6SLIR`4m!WhNTTMuojDtQTjA!rJl!?q~v@Okc~OHs(jEMsb+yY8i8eepvQ-a zr-s2nvlZtsM7*GVDxuDy5b_*hT`hgQkXLU zwek6lWRqu*3#U@&4uUXJC;T4p5P(UQU@NoavDDh{CZ70BIq0^OF6aS-%iSE~J_b!Sl?OFbTTq&xxp8C&%CEHAhI5YD2k{yN}@|mOrv&mj?B%bKbnJHI9rgJeIS( zK!-}1%0vCc8H0Z#B6sNU{KX8>xTHSv(?JGfvHt%d%}=K<7y zW5(a55}qcSMBRI?W5cP5fc*HR6iJDsiQNhDcWnawM!^IC;0uy}qV8-f;Dy+LWMLgR3P@v1O2J`M(D?@TcHm)fyR z?_9BGB=5Eq3UYD5jmFIG9|ql=eNc6?+Fw_JtYFhBHMdTeE^$DKQTN?*GmI4v0zx%` za&qa?guJNYH2Lyr@6q!g9 zwdbxkmJzq*{>Y2->tuv6Kil7aqLFTo4}u+248Bj+ed@geYBfbSPU9vih4_#J2XZp- zvybX$f0gtRiQZ93-Z?HRE9~r zDsqBbjk)FHhEj#^TMfbfTFUX&VCo=n+A3eo;EeJqBVCqwi`z>N5=6eFDZ9(vRRtR1 zUfm=SV28e4W}Th9NC@rydwYaKhWcM{njdssDEk+ruo^J)tC)|r5*iU5C!;!N)2-a` zx|B(rfGajehX)7mfe-if8I=eLDr^8PU{+XxL1(b4MePCZ#mCB2X;vA zc~yVFODeN^h1@0^<&Qqpi>wIaa~oN*m9 zDezn%Z`3KJ7>Q5C(!8UUFvu`Zmd&&eNt#nsS>+}&sU``6+JDYmsKi1XGA6JrjrUXV zz=QsRE0e4lXULp!ip!1bU;kJtG>aCVti|xJ$}7l|PU$ko zu4U%jOnQu>izO^uM)5P{Y_T-!~2KDB6Q zjk`6n7f-1WWqqrAuw*kn`!XK)>Sjg(zQOpzZc<6((mBwBgW=VF9~!;|CfL!7HMq3A zOiZXqtTwIRvF0Os!Ig@!sB84~z|WUB)UUJN4<@od+dO?cTKnSK=~Elo^7iZ6ypPd; zI@I1%7gQzbw+m@G%<;8xVf=#QVZfRmaGSF^CAjMYsHzl`f$Elp@aa-u`%0rWSqtR> zE3|7da3i^JnXAxXVlRFZb~?=)qw4NRbdt!F2=%933*-w#4~ix<%Y0}Q#S^WS*W5Zr zIY|TMk3r|DU9B^N6b_0UZ(`!}%N_M2F?GN;;E)H^*Bs>c?O#iZzby3N2)u^pMvJ@T z>6%pYZ&slA=#BKXgvrd>BU&_3qV(W)9zTsD;_VE1{6@6^=}W|6?Ioyf_FBEX62Py1 zsk&g|@$g<0M)pxYkK;!;F3})~sPnB7AT5~=;W1{CxC=|3H)a-B3e;p)MlCV4@>{BSy}?iUGYyV$3^jbxb8&OF!=m= zg5OdgZagmIz@j4HHPwtqq-Z(tK7RD$E(U=nmXRoLiom_CK#0ppAw7xXvOReyw7|^Q zCMQ1%mfuOQ<_OWV?fJsaz;-`cMMR-OS!!$2nEFPH9vSzbme-*LuPax(>>4%0_oW)@B&qOQYK!I^C~wXSG$F1K1*#s5X%cb%zkPuIpZBO8t$k7cq90+kcK zzGVJizyYBDair^)%dLx2PnE}=vx2Lds|V*sr@=Z>0f`rp}KiQHWggAd^Ds23aGChtm%^8km&-$NoS<9 zA<(rXFQ;AQC4JfUt;%a4E}w^BBzo&xROerCsb*{`ng`P}%2)0k*aUvW|GpRAeQ@g` z_Sv@r)dXK933~TQtHe0ZdZ;e@f_Y|PJbJRAGB6p-Hx89gExq0S-xvX^K zeVbs>F%T>|HZW3jh(I7R_=9<>K(^zNL}NjL<1GbPz%CO6DadmuQ>=Q&leA3ZVEIxYBCK>qTinAjmiCj z8*@Bl&;Ye*s3-v8n-vJYZYZC0j&_6Wn$)yr7hYMV7ofVtAm8*FVAfg%KH~m16Xsf{ zFL@VqDff3#{lf7MkG37^b_OM#AJ! za;h7~#3(k#2^;QGgpFzfE~}ICe)cIGaivf(bXLIjDv>@SzVp3nQsXn&K^qmAYDq5P z4%=@@#`}~33wn}4`8W$KEEACBq2AEm-!OzBsJxoWvoZFNZrpxO8C6&&f%Z>(9gvT0 zN$hTEN&H)1$rFP7g3b6M-Jc;6#YcEP+U3_&8DTzSJ=C@cMpS#)P-F1X=128rJ2%bI z2LKjfGEd9)Q6*oBJ5Y;DUWuY0-wh_}ZjW-OUoiW^a+2JFGo-YT+LM%hW9y-BH1FAn zh}%h8;h-d>4|9As(<$Yh7|ochE*5210mT}NyxHsZJ%Il46|eTZRu&R00w;5-_Hkp~ z?o-Q}ElPeE7?-xRWV9cVb4xYoA0Zv>36}WR6pVlN9nG5EUU=I#C!g^V#^Z^)=2X3N zo`L7J!W|)6&T#6dIA9RVoyka*$={lxvZ2$>3wqJS`+SScjFFXWztGcq5c+Y9w)o+ywdH(r@tVC(8aVn!u zJS}n6aNjO}_yIRsi~hGNG;l^_*UEDP3y)=^w})qyx#c&7?(!VumpgUbBoI)YqMh&} zeNaBWbCM9bOh^_?!eHi=Ew`7jh|ps+S{j|Lep^no$MT`iRmKwr)fXcD_!AWn6%qz& zZO6(Xl6SDqO&E_LI+&KdZ48F`gdrfI0h~$k7(R%KjX=}r)+(=v%?PU3s-;ME1_Sb|dXv-5RGSJW56t>X=|$8cyKXMRkqt{O+j; zm(w`OLJdwUgR3P@XCz?T9p<8t-miTZE;KL_8I8@n6rYx*VjPmsEw^~}f&5rT!(kKN z_zwNWq$F6kN0rpGL=n2bI@(b6grw-?kJUtU*0qhv-*5Y0|adLA%0 zjSL<|!2ZTE1Xg2O9Cwrn8nHwy>1hB6$qh7>iN(V94;hPYjtE;`FTX5@4s`_KQp{Xv z4G%^K6tT|A&G%`)6A$k;aj30Efz<3utj+Y2|&J+gwmG2i*(j>~HV z;PSMiKD}jKPB<~7vU-)`$9+{!^2Jg_M0Y@Mmuk1Q*AbQ%=K+3{-%|^9;ZD{)+MA<; zF_wgW++@Hv{M=}4V?nW<7@GslJ4|oGXKF_whX7K<1j>OzgYZ8TRfn^79-gZpCB&fS znCw!D;5f*Wp%1gR`s_BY(0!Do$KxP13cdRn!w_MQkd*nISw%&(IsvnLR2aL=qW5N4 zBw0qNOGI(&HP;&RYGZ2EOz=K!L6L~}5H(T}r}erQ6N79dd!2ZST7f86W3C_+LF$>d zMh}qBmtSD`gyPaa%}T7Eh3yxdnY^lNT1KOo)Ju`?wk%az3Hp`7)TbVR5wPOJxTEUe zSuU}8R1Z?ih>$&@-t$ks7*k5jaua4^EiZq;eL;300n@)csT+v<@m%h zUcg5-&!*NE#=X483K6BNdRK;@Iq|j!%U!yT?z-<~s7l?nV;PETm^_}#y7dB{aYGim zdL+Cpqzu6J{t3w*YeQ66%WX)P5kr4Mi7;vsqpiG^pt-8p zw~-7rhtgz4a1WPIUeGT%&ze(|qO7+%tcS%;;8o`6%jZEysFB)$`o{dc-@0U1tA|lv zzsk4(y7pqRL|eXLvCnvU>res0lHrj!5#Lx$NEeBOwP>=1s++9%8}fOiB*qXlUV<3m z=ZO+$3{M5JK+4nhjYjmXi9`Wd&>VSp3$$Lo;x0Z+}B-jkFBCRNHF@DPwg~n3n zU6yAWahqy64$(==uQJO71VG96YHpa}T_1CFdC0xCITK*cz~qPuP}Z%>S0vlah2CeRnZ#mXr{LhGU93Sn5UyrbFrH9(Nnl0Bp_c?q< zk=ocuh~$(ZT8Fy_!=NRn0b}%X6Ofo}<41fcY_`@A2k6U2Dca0!=Q1!4)TfZ{>Jn+2 z2Z4Aj!kEeWfih8^!$a%60j+BW@Avvn{vg)FPehA2DScUI)~c?gq~b&PEh69BE=k^J zD?ljom@Fm@YB|Pp1r~9#p~+(N*D&IUODo#Fn%M5j&@A>$bE~ToS1&LBj{)|n#pecc zca{$s5de9Q&K1_KwhU_~ivfag(k6GLttU@aJZ&wKj=_{dWtc)|CpYX^W-;sjD(xE* zRxOFD^59|l;a*w#&9tJU1uW8bNwS%+&3O7g+jxA?_P_-=&m3B3LKZY6x*(Ecq}MSr z#zIx{*)_ocvk1cy6 z^G)P3llNHH&*OA!aIOX@Ea^22g4Nk;)Bm1&Rq?<|Mn#4X=pVE7`Yf<$*kW@9|BlzoJ!rYX)5E>i+XcSm-6Omt~xu1vq+$bW|t;eWjQ_-7~)mbHG3{GXu8zu!AopG)hHdq3p=H-kF6 zN!POf$sqCH9wtEVzZmrXYY&0{SA#Ef|8EA>c3uB-51;?FhrMe$|Jfkik9T>09z&&F zv;S)F;ZJB|Z?1OWe;h+N@4p(9-_`lg22sOX{$ely$R+vtU$LxPncv@0xU*Y{Kc5id zhl}rR}lW*R6= zkY6#oq@vtW{tGTs>Yvd4o%sJcQ1=){kOzmN=BaGF7AZ|EYKe`*6WjVt5n1piJLgrS zd&4$vNI~O*rE~472{Y@yHSEylkdv+cz1+hv9s4UFCyfPNrx1%tFfwOI=RF2mtX4Ps zGUlk`=z34Rk6hL@so6U9Y(OT~-Z3VBIGe|Tb?F=vRV9V~4y)2=gEF7HGQn&^O+9u- z+9as}Sr)dGW0Y`zHsiB5gBfiy6JsU4AGa&)>cN>XT`rnItUfbCHfx_3QS0o|_=9(8 z@;G_t+*~PAg2@F>*vy}ML)E#LOAq$KH8?1Jltbsqi+ZfprHqjVI4krGVCuX2V7FQO zD))wI%lglJG1#>N^i4`{uRR|u=D2N#(huGX1;z*c)8)SK-#iWYbJ&I|RcZ2HsTmJf zMDfHK8UqHedavF1xP|Pw$$w)uxl^f~3AoAj5Gh3j1-E3NJg^xjm_8B#09kFA3(~Qx z5LY30t;G-3i;;&2;~o{y*~!SviOPO9@`18y6wQ{q@7QIHBOJZ#6gcLbd+;?V%zCFW zP8z`5r)o*ivrUW7Y;=&Gh_Nmuonh8yj(g#c;N{hubM+krI_R)b>>@?L)wVnJ?Tj z@VVz7SZX;bn-x48q%Pkl1O_7ddyQD8W(^apmkvMLeu~!uS2m0w(X9(1GHOk4D=X>q zmyopdI8DOGACLCVUjM+p(=gzJ+q?2u;&eSjBLT`1S}#7ZV*a;8&28 z1WA&QdoxzIc+#|3UcK;8xs~S4<^hk+8zIdRScOz|wGOc_bMB*i-^I6t3{*c|9jU2j z=kX-Z>94;jnB}X~mDCrGi(8O{g>h6Wabr1J5v)q9bMgSsYXa&&{bL9K1yx$R1g5#? z!DXkfh;F<%Bq`S7su|ONAPKYjk}dgAmj=C#(=8;%Lm(c4Lg#Vr6bsr`i&~W&p5^8$ z(0cQh#b!6Alg@g({(>_yP*5avnQcf3f%A1a7I1yWE~pUBse}Ht1)#!St?QB%-LE`W zP!@410nNI$v}9me0E}2_N*nu$=Xt#pB?{Gtf|78ivdWLHp?TAX0-akU6{%v& z)1oe@8`JPCcFyATxponKLuIFAT;Rtb$*<)O3%-Ejih`8ZT#q(H+}*{2z^k(5+D_wUkWy;!if$f9epxH>nq9_^G2@7WV~ z4eO{_gw1#wB&CkVKge+k;|6+{?mMJIGl1NGprXLif80w0TMV}IoQ#Z)&UWVy^cco- znXSm6v;w%1D;Wa-ct#s$wM;w^jWJFuNDEmz{y-l(=Z86Cn>_`GtXbDl5JZG<7m9)0=jLsd`buc$G;ZZ=3YdcG1Jw3dD8z) zdqMXQ-7($4_0GD`;w$8h+bo_C#!L@_-dWgFfZ}b~v^hahlKw25VNk z=3AD&HnW9;$$$DA#!PGft79T%bMBKu zO!jOXJ=5R|U+o)m>eu_|TR20&Qn#Gx)awgTh*zZc(hJ^q9y0M_NUQk~dG;Nw3p*r% zvdKQyzuPZ~wnSo-Zr!6Ss?rL5WHVby@8eP)&|7WAy5Bt3el znfnheytiP^l*X%M6?m#&IGJy;OBaU}lIgLWjzzPT zO76fi!qd~~BHk$vZ`=2}+1=W6%S~u3=duV;&I;NwlQBD`sAxKfXeTIi%^_dtd)lX7 zy{+=0a^yad<9BN`_5MCTA_n!9d3u3R7Ja$LvpBJz`;_PExc3FWJIxzRFT# z5+u@fBGc@J=T6j^!xvaTWE)5gU{DKr`5rI;93SIX(JCn3ELuT_gt1kl7dN%XqeR&3 zf_IA!FPST6yLUOXHqkIVSJoLYU0&nPEODr52np1WaE2wf_*WfP?V>PNHfgr^jn6g| ze#J#~Ek6t3DF4)wXi3etP(}Rl_YLo601`(ydtwnBs8!oV%sD{rV^!&uT41EtX~4Ab);l6wLIV;DvJD0)Y-#QKPgPoRw4h^(Mv< zx~F(#(QZV|ijl&Ac{V^4ZG%_Xle~F)TjN!8xK; zNSpT4xFp{=7I1LLIxvy8DbR^HZaFmwgu3@?Iph9kN zJ84MOr2+Gak34`uWS73)6FNs2PO`pJRkuR}U*9>ZArhPP*f9Y7>rmh{MJo%=N zrjMt$Ljy@rqz1=y*^>UbI!r}S>ZFlIeR@**`3YjMdgG-0(uiJ6)?r;$$6N4tab1;W zlC(zuDLs_Ed$_n8y^deNxt#uij z{U95;D3et0rk&8db<+<02b-jUrs%dGXHI%yTjZF+X7H5u1p%YNtftB&v2YVZ3^?OL(-rKQPuE=J>qW~jg1%MF&Ki@TdKMEd^YOTNM#`v_p zhxfyVe((4QmT4LJD{wjK-kHnQ&Tw55wtk##87;bqKffpgr_4z{=N4tLtk+|Cl$`ZP zEmJ!^ck(A9N~};Ry{O6(LZB9prcRfrEK%9VH@o?PdPX%@?-a>5-a9L&FHaY?DndgE ziw=z-yf&sYBj-cFF`Kd?$mC(%q$IbMGSzu&uXhiz{$BHnqx3@=HRWgWJ-cJft>WbeYX8lT@S_hi68qk!c7MGbQYlUxFIRy8Mx!4xEnXZjVMSOc7 zXuED$EJWq-vVI3)c-yR7%CAZ9B1w^xzak_ZYVBeZe~$mtU6lF$5zYt*n%cUgyGTJ#s`F7f zAw1=~MQhW?z8~mdIRyD;&s`;4UPC_dU#oyPu(q`LW`aX%pmZ^P4@o2$IsdPHf?efn zsIKVi&MRPJ+iki~df+cOj;`b5PZ#?Sulp(_9^nD^Hm}%g4P$c)XC-@U+a{r@AIBA4T6+ahHP~oyo@C)t}dfrg-eRs{bG~=th4B7beSQ4Ef1DK@Y zST&CP&u&YNFb5LbD&DCVMZ8l36zcubD_*P*DoWwwi#LOx1zlbI#dLg3;^`;u%h564 znSO(ai_VLaLQZ@#?5#5ovg;nS87?B&ED6_O4y1-#mPTRR>%k)NOJJ8YPK}UsAW=h%BiNc z=K{qy!O%Cr>SeOAhVNqd`IHtsxj=;gD@fxP9NwN@oL?h%)rSf3OLDd&&AmNs)X9-9 zmRFVsx_Lt%j<}N7;f;%hQ zKm#QPY3Tyh?o)cBNB;T7?J@KN=ec9=BKJPXuus_m=SOeGD3-bzA}hFs5g#*RSjxZP z#8sjb7#uy4r3b=9Ri~@C%mA5d3H&(Q<(*e~;jE$Y8jA((*2<#dhkhQairn?3ohfgE zdo=FZwywm;EL8;?#T`n?)vEYKmn?R3V_KGZ$JZ)>)^;|ZxqWa^NwPjigUnXF1DB5@ zgE&P!jQe0t^a`&bRhUfZIqFZFf97m(C4YbJ_UCk}h@arMAO5Id_|)LtKKQc;^<{@@ zwTICBa*yi!9cU6&*$-A&`9U&F$0^$C+>kyo>*SeXYMif6$X0b7GT#zc?uT~YjQEYi z4mMc^N=wW}O$z+OV@jGdP~mXZx=RImbk49zF3c@MjGNW_-BbHx2G)qQP*4}wV(k>I}K@C`y?nGs$*6GU#d#{EY~iRVjA3Qc@U9x$@K-a2l`PhWdAi(ZT)^|1 zg#NowKG4#%mag5HChw$fdN%;8A{F_pf`5bci>$)#lChLKsx(2+rG}vusWgO(MyRfH zn_WUh1>jf}!z3t_jxHD_fO@UvlD5b(hwuyTLsU<$Uf^_q6?R;hGtcKHWGuwgIHQ*@Mfi$x<*FKqqZYrly(Qy#`|7$IH`+wc#BD_H1n>lI@ylH~ z3}StsaahW??#Kl}k&mg7mSVp)TX|5RY4FMNGArZe8b%hQ*Gl2sB_slgSE*gkb)rH+ z0;QbSKso^LbQg&NrpE0Gd8_WnVd~Nstk4hpHu=IJyb(?It9k-H9p0E~f&I+P4p=3V zFF6+DWkLJWrdRD-1{Tv1vuDO2G7QW4F30i?{o)kXx2BKBA81-4%VQ^ymI|us=Z7zP zFc}iN@?7$((!=qimrM8Zy%D0-1QQ0+8wR!UdTLZi=ciKUcq5MA500`=jP(<^zUkpG zs>{=)sw^W-uHbdsIRLi;c6KFcPw|7iPIMJr+51y3-5`$F zOfw{sR7wsVYJSLG73kkSa1qiW2?9v>cM>m%(%*$*GxhLF6JDV4!z4$i&=V{PIlAML z)>TSU8c}(PB5t9n;76PGhy{R+6p&!IT3_bD8*0vl8$7K9cPCtOt^ML6RibJH_YU%Y^#!7(a6nY_P*ObB?T#21!I_TARR9TPI;r8 zNIR0C-a*cWcl@oEs@Rl-d(aZ01`vy5lGZlguUig(L?Mj^M)~pW{!P<7Yfq9Q?gGJQ zO@s#?geJ2J`YB)nfL~0OFUjWB{xJ?KyLD_7y7s5BgEF|Q!Lf&Y-DJi^XD{8iU^CM= zj4_I0wI!Ssqq+M5P&%2xmrUKVQAjI5KB1KD97rk3ca7i9O#tA_R1HTmw0qB)=Fb>{ zb-njKPrzjjnBE3=h)3vhFgH2un=60)K~lFq=S1MEfKN`LExo{f{_p}Pc?CrnM! zx`>Yxt7+{IAm%qWW9ENzGm;5smbP0cMXj*Z=;LuZJ}!+Oob{>V7C{hak(Sy$;cj$Elf08~7mBa>vgRWvP_H($#910uqd$QdUG5WJrZe!)qw= zrETQf<~Bfw-t+N6A~;*Kgs|Lzc0NZ|17MrIatFR<@yuoRvl?Uz{$hFjCU=hfvVAU3 zVYba~Of1o)b`<4x0~sSV8;AyAk>wD%+pIWoC$;ZE*>N-irZt}@2m``ARJ>{z#$@$W zl{H45y=t>;&oB9+%glu*$1Yqyc(r42k8r5V-t4$;lQO`VrRDpPYMdSHM7$g_*I1vN zIKm<%erW|hdlVojZjF`IuDVpxQf~`D;DnXU5S3PzuuH%AOi|4y5WjT1ifH8AIIalr zYt)5IDXwl)hD#DIa<1y|Vz*ans(<@|#!~4Hj~o`~;*wg1p{VJEmL2hr9xix!X|I?f zLqtAI4KVZbC5gULeXhPP zSkapEqda?rLAr;xYtt>hh~1Uz)!9e~8{ik5Y2|&>rO)xB5wby~6Q8rur`(*)sFjN^ zqAwo{^2aNBbV6+_c@5q)Fh&&?yCI&s(pajwK<1Q`fNFFK1XK8t@PeS66h5yuF|6|~ zBBJ!M4Z+P<^<`xa>7GGj?7;=)4b_@3zAtvL<3i>^xjn1OxE_p#oXP*%rGCcL&c0?| zi*JIykpUylp!=W}=C84tUN-lX%Cn8Gayju>X{D!)rBqSUU-hceXpkR&VExJc?oRO2 ztetdWYI*E9sfZjUZP<0#DL@0*`(VOk#du%13eo8E?DjlL=@t?l;|3fAAh7D&GeiZA z(Fdnh^k379ESU|1zxz~X)rp(wSw6*4DL(}mc@I8C8Amc&8?f6b* zIpJ@uz^^U?Fc8|zGKXyriklVtHRSFhcA!ipbqoBm?a{oS%MP~IDQ?Q8&KSPz-v9g& zh~P_}W3FMqJCSYcE6GU?S^fm>ZI(PlLE}SdY%ttl^bbo-mKQ*4QV@@8AJZ~_tiZru zt9|y;z{ch)%fTjy#GIGwn&G`IGIU&6u}7l!gS;pnfos3Aq}ko8Hcu#28r0IG7EltR zD|HzC+PL?^U1M@1%Yz+iuXruXX&fB%SQ5o)t-jszT8y1r&>Q|OCjkVFbHe34t#g`d zu(WhgZw1gpHvmI=sca`ij%C;%pvA#?kfClECXjf*N|*6rosk1|g2AQ7`u+ZA^x+ro zKA8k5}642;KUd8ijXS~`ICNq#ZE#pcfJFq#jjekQQyV{BR zy)Fs^LeOMbf;;rhHY!i)3|BjGiTQwz*xO2~Pb29<6kzeO1|w||h-m3}e9h?$$K zPi|3Q5wg5_?vwQ=XLkL$`~Tw1{`{!(;gn7RYrc7?>g9sXu3k~?r=6CkxFfgECA>4f zx`xW4J4GdN`I%68a?#=oa?xw1114b81zuE?#EOQ@<0wtd!}lkx-;wg2+n!ai3^R`3 z%Nzd&qQV$aV`>I9(vNZ4q9K=cc8ctJZc#)I>15XKfYmA>FbRL}J6}jZ-bLxZf}|ZU z4q)i*zY3548+vQZ^#4=CvvfGa$Qu#^!PhqxV#ewd%QiV*EXHpJ%PvpU=ny6p=Y|1o z>N9E^mS%D~?EJQv_dkR6_>;zpsrVB=fNXJE3 z8{yp3*!wz8uXx&w+ev=pxM!9`t6*|%(TC%mbh8-vj5;eKt9@Y?ymg1E(4`(li+4Qz z!r6K0T64|9o8Wr;%N)7!GrXjrb155PcH34P55LiJ6_# z5OswYa5z4+LopYAc9|4seG+^tvVBYTf4xiN69ltL(A3=F=Jku+HUvnDnRQhmacLpv zG6`hiTvO_0^AKq>Nv91=s_zK{t-h^oXLOh~?X$f<=~2U+UUZvR?>n172Q#urm~7?7 zl&_nfw0qmL+hHlsE4q)*jSF%f(peE<#HqrwPV;59&~=*BjA7o5k9R-*bzJ?Q8Q%1J z_0Qjrt946!Mq4aP3hNCtFWUCvMv%}EouEOK*Si-3;);tiInb3pxsCcbaEmVn zeoz550kW@wD8H4rwuqE+Hiw~=SJRK`;agKmpq}XQ@(N1vBTvV%f^Fqxwo}|QW`BfZ zeweVC5Hf)ZJ9+t0o{a;-hq8Nb{axuys&r1wnL7< zEsuyl9Oi=J`)MNoi0~s}jHVOeC>yJT0@sfk(`!USQfiSNieP|$MRhTw*cU{{rGhr~ zH0Rm=-mzukllm*Zg-`K9m8mM7p9Ln$f5P=G=v%#ub+cCPUpkll9uTK*m)`%T2o(`w z!OU7tOQU(|lypR84fQ1a?(DC>fWPC-)}q(f6MqlZeDRss`4pe z`d&+9J~_Ws!h?2LFgqA-nei~Fnl_KGD)IeL*zJ}D4mm{s0479Zu`DIszS94>^k5@t zpWt|PAByA>jRsc}0jcNZsZROOXeUToAilT@(J`>aZ_}LR3%=pYfu%yYjK;09g+I&E z6YEXRQ z*V>FuKTNJVr48T(K=deFKk72trin}Pb6p+QqL$pS9}>dP>}__5sB;lxLT@M|QzhME zDA|jb-(FUyciriXn*1!^dl>+{B#~gfi=j&O>wiAnVUD_Mct?oLh6pjEU)qVl4kDuQ z%W3$pZH>8uFaB&yOMONia8;Rsi(StcR2-Bi=+GPDk@NAt-Rj@IjvLxROzW4C?rF3j zt|X3fRAn5GUr7SDd;TngNTUD|Jg*TZCT}+5KM+M zuAT?J>{GF)SQ*-pxYBJuL*wdPwkK!Lf_EPO^6UKl3x64pg5scc#Egz6#RB0dGBd<^Gx(GL^Mn8PE@)kY zq*c4O3%`yfwlTs@01|Ts&YoWV#Zef(-UNT)1pBFfF+Dbbk&pbgA^a&DZU5!FCO7M% z=Og5T@?5F5k5bZKMnf3pVH(=w(NA2XorxX#TD??}0!nzQ4*+l3PRx6<7KQ5|e6LHE zpESO{Y$5XX0NYmsF?df{vQkHwF1QjK!-eTtwCU^PM-C%@x&~RW^OUzKaTq7uKF$ zULJM7ckGY9g9?%L#MVw-gfC}yxd%SUoh}LA|MBUc{~J8m2?ru@&`fz+TWii`p&fM8 z!`Mrf|HBnmN}?oh*>;>C^s12>Z`z;9+RFoC`5!KeNeT{dh%XkSL-%`APnyW+V#no= z=YRDxbgHp{2OfZ}(4*&O%dql@-{6t_Kl1yeEIIx^_TD-!s&#)GMg@_UZj{ae=|&Nd zZiYs>OInbSZX}0p7+@G0h7=^FyHmPBNhy71yU*TR{q23uKIi?s@8@~m=kVuZ&AQip zuRFeReXomUF)gpa^`m0$NZ-1QkCM_MF9#V=0-KAQ1AU*Gr@Wt(Wd}9X?uCll7Edwa zP+aK4b{^EIR}-eV8JcHrnRo9MK8KPqS?7896V70J`FaQAOf{|->g3Zc^2Xg`Y`Sc;osVM*gkuRZ7;_&89w;@!1hbo2#8g1;E5cNQY32x zEy|#u-_k6(K6Y4l+qkeGrt%@r&?gfW<@sWU)+LL!(aXWyk{1G(NegM1xmK(TFmhM9 zfr{b2C-m`C0=^+S&39_IrUMt!GkIKx8lRG@qw5Vnww9%?w_Ia2v|B7m0(*W z1_{@GWV8plr_l#A7#^5VOvgrPO9!C^7|efqW;bhab9VUV>4r%g)l4INPhf&F`$M#~ z*36?I)jSooHpU9#3gU{Xjm>s&YQxQU{{V)=!@%%(WWsJc&_hq{bGAqO?^n`~)%))K+)QU>{yNzfjw9|yl;~M}P2flY~3%xq(KsVGXrwp)4{KAs3 zqbIyD0N<;$R9ibKG=wIkS}#3bEeWN*!w zo=v)oto~?Y|BLS@PUL#SoRE?=ry6r+skp4HHYS79&QRg&zd|m$h z?y?o=#n97+Qrr=MFBTtJgM#JE(v{B zmz&75sLTnm4NP_DT9;3!9+an=wgduPdP|wKUnbA5#V2Vc$onIAJD-p&bDOmeygNjN z^ZB{Ksp$^*ezd4PK2AJO1d1>`FFymoxsrYfCP4l`;eJIxV7Ad9w(JQ_%N^ny+N1O` zR~j+uHHVGwVhKGPR7%Y*tkEs9TBS1|Bt|@1>dSH8-F*lSg((;t5L`>k${LwBsl#fe zOYS|MmCA3@NLAe&Rq427NB%JP*b(732zSQkR|QqfQ$diEhN4eTPlGFL;p}I>1R<3F zNC2Q7Q~6<{W}a4^~vru0?QCRk6^X)(f`iA@<)xruaTF3{*B;_b-!cN|4zpa zLjIlaaWnE`q@nCNN%81TK_}WI#c#gQpCbPM)=K{!_x}&4|Lx0vwzl9P1kMq|R|9h5 zGZpO;T=0UhkT8o<&}=WYmv2$PvfDLA>gSaGbG(0Ny~3H4eHcFX#?$9U45cxGX9@CQ z@e>lksLL8<;GCFc{^b{bye}qQ?`p_sH9Uuw-Pko0Zh{&QZ(brZDvl-ftXvBFG_sr@V4Xe;?bBv{>5(LqF4naNOl_S^lE3 zYAdlc3z)s@PU)KX1t*-YAFgD8x&Wv)Ss4(PV?{X7A=%N1qG&r8Os5fFaT*ifxV>6A zxbvdQP$%E%ojq9zI;J)voVPzH^S&0dZDpJpwJK@XLHW&>)qu^S_Ki_C84miO&&rBMn3)aDYpg%X?r4C_DCc5lhICykXEyNUa>{Vw9GzGS%SG! z6;DYpo=%WTCg;^NtVZ=tMgCUyR?JjM8vB@AUQlp;almp0ydIX*CIkhhHG{D+6ks<2 zXK3k#5b5T6VZa*re(|gwD7`?glde8>zj4_8ttoMhv#2aBdX@7P!Gvh$y$)4TFr3M# zoD7`8}ts+fOZfjCImWqmSYZ_x@CNN@YhZL8|x+aPsXzUeXG?i@#*tQ%M zkWsBarRmg%=L2O5?kb#^#LhZMM|o!c!Ic z*m1|!9LIZoe0GrLrmv_bP5LTP)tWEvoE1FjK!2bo4_#55Z%EbZ$gKbG?f)H_|6L|Q zRcuri$K^;Iy}3>1=ZF9F(8h5G-h6jx=b|#p<0Wv!?V(xoT%9FIAlWG5i{D*-_}Fs9 zp246NSH?785!-M-olBcl#6c20XT>4TH+-)Gv#PRgc$&7rKh0pm6y$lry+lWD8u+pE zvJ&KxL}ShoY}LuI0Vpj8h=mG^0R4t$jkXCeq9Q}rU!^I`3U7EW7-+hh-xR3nv=@Z| z9(+aM(w}k($|dLzI~4~sAEV~op2N_4*zoq^S+>z3tNNEBwyy}|JNW)z5hkIOYsfc_ znsBnV5s!|Ff_MzWS05+>3^gAOUaSq^El`%lHYqBSO3O-W59yR-#NQP#C?7-y$7ynw z6`6VOTA{;%F;3QnTY&KiF^xyPij2{Xd@#M<9eiXG>q0d$%hGHm2XKSGXjjS(#xIw+ zez^4QuHP>O{&LrkKm0c?eS7>bEoKe>*5dEa`gZBxc-HSf{PyP>^dG&-w}wb9q%|-1;1zHhKtkq#A#Qn5ODX#~ zZ<9KDV!36rjHgW6iL+zad!d{20*Sb@Y=JWP`NcBh+z7*|pvE|+9cFG%r+IpE9^X7- z2qcd@Sf~-MEr?iYp&RyLBIayX_;&xDmq36&_Ee{|%dOk7$T7JYD9Nx-f<+GLeK_bf zcpok!CSv`ob?;vGWiW8H4cK8k4MA%>?B2;-TfMI#if>C!IZV#g)pq<&d+$k+gWfrA zuc*8Z*-8A^APF05hQs074ocDH9n0b(6SB~%?uGQ{UDB(_H@V6%I@55y^z@8#oy)uo z-VDIwGnMRW=Tz>X!*kBz?6BgeSzih%U{>My6D#DmYjxJF+_%lhQK;vt1TLg~H}Br- zO}Yh-8RC$1nILuyy%2VNYFx=Wxa3#9utRH{5|q4c)z{?QUm?G%s6YS}u!6%(Us^7_ zdvJZptsb_`*psQNb*Zvs`en%2N{Vqe&&+}enbL@@V##WI&5$YFB>yS}bSj(k&watT z-l6Y(K}@G@N!$GH9aDSdbRNt$8m_ZmjZQE((IFz2?4gwKZ`tNF^=5!9l;4ebbvXRMcwIN>h9B{Me z;vz(EW==0X=0k*uu`-glM><#KW$EGEA$5TlBSS&|Hn`8kyZEML_}`kUeknmoWch{B z#&_*5n8+wCDbW&~wI-wl%?28b6b>#L&j6<0SdI7%9(CHpk*^iyh)*iJXNJ{R8pqFx zMJq9c0wiBb>Kq`LM9ib>rAfib6YS=g`pCPUFn8AZINQS}?V`^!4aZNa;Lz>v4@UcO zRd1gBbH9Z?A@lo04eGq8I{L|E64`WS4K=*X0)KFwa{Jja-iunxT;!h0t0dS2V#*^$ zhE}Da@=vL22Uoq!H9|Ic=bGAj7o;{f|02%yW4-ql8!yi`6La@Wqr!ec&eGOb1Sb}G zI1sR1V{|G5Lp~DDa&d|9Pa`~`LeMYYM|cc1!}B&1!9A(9`M8Pm6YkDsgL(27QXc=_ zQw#u?Z02nBQ0e83)Ap=?YcAity_9q2c$LzTL1C{e@c(hMUlE9Wi;L(A+*c>4EhSpJ zA2irp>9-YnFiAuB7wJ>Xx`y7 zeL4A^u1%8GIRzP7#uir8(UBR{FAjixD7wW4(86p7Ps`-MpDV;!W7crIh%8;?pd#2QlrZM);^d{|x=!)pt&{JGnDvB!zian64 zraGZ?m0X@v5RKH0wWG)ermX@gh0qfjlE=DENxtfM>FU9BKc0#)a*mFlhxGF;^N#8} zI^rj|Jw>?ZPobxuwL8g?#HR>C`lXlHH_EHvVsml>b8gDIlrW6aFy(&w(rvO@f32}p zqhd5smjmQZF+4 zQ1%{6$hzgU)!>Xws0NCwRKX;OVR1Mhl;CGl5wG7j!AqEZu$z_HOE`rqk)5)^iPp^T zs~9)uEA#T(xgOrvT=qbQkej7Gfgv>)7Msz)2lS;uS-w?tm{h(KfrbvvQ9$L~&N`U? zRwWKYXy-!JhIWN%3+}8X;{m&60H*{c(P3RNjIg9A4E)OZ5Ty@S@Rp$KOuDFL`5c6K zmyXUL48h6kyw5ORb)MN46w5xOlDw#52ax8**cfq$mgEWJVMuGiRncY#Ks->h4onHn zcn*DSaGB1-NW$oOOuq!6HqlYQ@G1Y!xjgxndq%7yJsi^l^Pq}Qdd^-Z+b&&BPhsB z&Px4`kc{3Y>}f2~Zs5sgTtNAA`b-o(NQB~9!l&Q!Wm>tvboE^Wv zq<^F@UtUjwS261qW^W_%vaG56D-ClEd+=+rwU>Q*ovBSs+oGX+2{1@9Yo*lviLOti z{;(OnGFLK6*Sfik1(^Y$;w>`wF1EZpKZA#{oG;*F?l|P;=88x`eL4;?jVUj~6rVDM zL6N+|C~f9@<7Z-8m9abFg-h^Z9$Ywc0kYz^YmnBd+>cn7M(Z7^->o8$MblxnH1Vs< z#2gi6xyw%^b-%n0PJI0QFfr|cTTU&vbH}ObShPe;YNJTpG0Mj?vU=Sl*XJp>xm?^L zNZoT@^Nh;N8L_0p|6p1dDK-9RdUn`19v)6SlE9a(i75=CRg83Cb~7zOxWx1Y44v~1 z0k9?}B5$F?{iLJl#ezSRDza zK2dg`oWojuMtaZrfrf8jxxXSOxp>Y1)eGSRQDMX@f8OOIMY+-h!)!cH>b)wb;u;ZjdG+L zMgL=`s`ClFma$oj9ocdJi~4_j2C#+=t-AO?WeHv^-%xHmzb;?QiR(UDtbp>b;-fYP zk&3G87lS=gM!3{3#e^+KO1@JTTP z9vHICP0A4DH_uT$r8gq42bDVv!;)N~#q$jF$`{U~h+DjCU+xL5@`T)czmNT>}+OX{d{n=11^ z+G@js+^=fz1EZoE6ufzMk8x`nRclR6(|g^x5LV+nfQXw~5tO{IInhosTbPsAI7>CL z9*b>gxoitd>EH@9yve`Pen~r|_F{NkVn(Zi^U24Wwkagn{0tA0YFoND%PB1e%Iz4^ z1!|v;yCCazjbR}00o^3CtnY+tjja2$aef8fX?4dAr(U!4ydvfGevjF zVI%vB@Da7({paVAMMV(v@lw1`wg`Pj>OgMZ?o%DpSnMJY=6IK4ba=5|AxlGzytr1Y zv>bp4(eN~8IVD_LN!KK@m%*%8YSU~6TkYi)rg9)2x9Sf5@UxjaQaY*>wnwK7mco#^ zX#4REX3QB9g;+n|wHH@rVr`}Yo)OH%?siqD`pJIR${?Pj&|3664VQs94i!*AW_~gIt>hLpS_!3x0~dJR6iCpV-mRc$;(<55d$H}V}KOX zAC;PZpl4ypYm%{j!>2wYgbBfC6-V7u^xjs@tpAfQWBw*Bjn{GbEAK-Ah-@V+bk9bg z>aE%&Y5*vl^mMD;2V5LSHo4AqHmIw2cf}NmN#R_nft6n{X+9o%ED!(lY{O6fr2j4Z zkyrfA>OQ2!H%6LO!C2c^9Ovsnr+68X;L}LMA>u;KN$JS``qNb2t6OpR;;Qg7iqZ{l z?|+$N8=>_)GHQKJ;^Hr4rl_pjR3)~}ZIvHWg|ce^msanx=>_%4Gpan}83Cgh^@Pz4 zj~J0%*%Uxh$@Md&*BP<2Q%#oKb$6k4aHce?&)STX#V?ac>F0RB{pG?hErbe)$Le{< zM;$f2QI!l(b|(kKU8F)ki>7i?*K23x=9Z?=3+N;?ia%NS zQiUudvl(QsYyni3Sx&VfI;VSVT-oGq26Q{JC4>}NKH662Zp)h`dKMi*LoaNT+^Ra^ znPcn$F{HF4i3Bqa0?y;4fDKS@6SD$aTt;=utU(FipxIe+yeZ38Rm82~ zcER(fzMtqQRXR3YM^gtr;`r)QdTy>wZPns%zg6K!&0G&HLfv))%; zQCYcNXAkRNo3Lp}$2-}_S)<;7xVcn^7`EOb_0k!;c@JAP+PCVm!w_C)St+|019A?Y zF3<7Rzj<&B7%hwIjoQwu!`k+VHEh)0pbuS00VglzB?%7pW*nQR=}RYrea}8-+DO;O z>6KPi$n5Pa%CU|0dx6b>gyBtVd*b_u=r=T!v)J|@Rur*#OUeH|pX?FA4PjNH1a}5M z@eaV{C?b0~eGRUw5vy9ekac^a!wEZ@RHAeE{e8S3#khMgyQ}`*+A}znDhl5CoI!JsBvjfcbiZ35o<@qwdL)k=hZ~iN?RjEr zCqbRuYN{e9CLL8Ubw2O8@-zoUVv|p4pDZ?0ZTK3CBcl%8->RyqsFB%msL9M!iB!QF z{Q81f-5|Y1PCY?jJ?uVx4$tw)G%fB|lR6tO7$9q$FC4sLM)w8Ufl8zzWfq(6q&v81 z{#8&h<1#Rx3+i?aAze>iXwN*l+l<;`PF~EG#r|l(*g1>JKG46nWT2s$8WyNG8&olU z>1J**WL3oUaUw?(ieJ5-^<>bPEVq)Taz+EW37+dB_jWx?6$UqFXhB%rh9`VXqRJ8{ z2c8R6P}O%E?BVEGoIGfWE>7qweHg2wSD#lfx{p^MWsRk?hmVgJ@5XOxHJe2K-m(UL zk|-w!Qa>RJSWM`9W{E{Z29MY_Qxxtk}u_&^8h+t8{| z?yg)OFY~#E!uD0Zw?N1`W0LDM6Hhaw(zSp%$Fd;$UFYeEBJYd_b4|{nG&~xXw&x%Zo2}TUZa^kszXPDQZ-S|_tp@HT2j{b3TZk<7sG8cn}{}BkhdLw zP2B#rr770CP~lhm%(l+6f}dd0nN8*K6-4!|VuNGhBEzi8wRv3eQtnrL0?3 z-WkxbAvQ?Pb2E!>)wMbIAhtBmFXTHhA+(vv)S8)q+E5G5yYH~Nz2qD-9-W+)<84Zt zZ3Ac&oX%oqKJElsy^?<}Prgs<2(b;*F^>C+FnGa&Y-_Z=-3BwVN6Y>q6SQj;A4czL zm16E4YakzS8!FfBd^h;!tOPVNqxiB(L1FM<_{q@dQ}Si|WuyOi)rn78-SP+{0a@%8 zk)P(jSuq)(IA?aM&YFFTG>lZO-*o97W?e(eidm4tJ4P&8H1F>p3$6Nq8q}a?f*cC* zt@KM*n^Nz0Qd5&sk)>?5gX3fx+D~fHZeXAng@vPGR!OTo)BCtkV^wKoypKIPNz&Nt zp*-O>f)63}8mc3$S4?>>T5A^V!bh4a%)pf(*ps)|M5=>0b+84C6Ikk#HyGqB?w=*Ce1Xty&4*y$_FaN1et`-x z>!xA#Zr*2ZsP1hVW{VUy8gqv{C;`CByaGp~)ynGVs3Vn;djY)n>3C%QU@ zSUj{@;VJz|FJhFM=J)DNj3K4fXLBWB0x))3^=%8Wd}JKWj8EqN7{Xt&xN<;=%8VgP zuvt{n{rU_KOX6H;o@2M!n-wu>=TcZw?}L`w1q7{Cwu{raB}Pfq(E&^|&O4oy!FMDi z{XeEN$lLgt#cE#|+pJN3kpXEf3ls~tKP-9es`K`t7qrN{QA4ZPSu)R~mdHFKC4n6s#rpFWU?xs++vG5U?Ey}8oh(hf+s3{(*bJG@7 zx(WMurC6SW9Ds0!ASWO2{Fxo!h!WOjESDB%MyISj+AOG`MN$!-HOZ5X@Ufx??XIh*r z>B;9)GAwib4QA#`dJI+eyqvmP=QGxvJm;LcNM8|jj=f{Kw&>>*vfH4+;-4B=~KasP{vek-Ge*PXLnk7CVS%ot(|~F|O}FYetpA zMo))y#1eR-W(o*xl9#HPJVisSocq(e?jc(udSzi9HUGyxg4e)*zT3}F{&}+m#a|$Q z0HOZWHTjEXKi_OKwd_F1m&_7w{())#DdPUCb@%5jSD?6MR9yGVxKkNV`M#Gmt@3RA zR?6{Hh5((B8EfEK;uY14tCafiH-jNd$3!PTLf}{=;L4md?+~Z}Z z;(_;iIMGVoY{t?vfoe(~SZY*r+(tFyd@f8yBv8of%SFr6AJaYG)w}&P6;cMg%%N2> zv#f}-iUeMV6+YETw_lq1WmgX-gjNoH!8|(>5Da9g9`j!leOT9WdDDZ0Um2yH9C=m| z4$`+?FYo!p^SJQ!bAr*ZMCmlGFH2DA{j+^09U<+Jw%H*%y~-`$A5g=8{SAr6wV9=m z8v+_EhP>j{UM_ZFtJzrX@kf2JEzj^|4l@py)qg66Yu|3w^3lzM@$?H!*16W9&)#ko zw7Bpd@U}5Z=am~7?CgNwc7NV+Zmw><_P_uBzj;{X(?A>c=&6}(TuRovyBjHA0+u5SNHL4^696UpHBAt7WEV#jn@me^Jug(-&x`uJGTR{yPN!Z_a{M z)AhEH#Q}NMM5`3n7{dK)W~Ur%ucl~%Fb6tU*BSFkpASE#O#X7MUp{J>cPgKTz1LEk zy=%A>AQOYaOpz%zjyz4O#ZK( zCr=%W1ul;E)j-ADqVf?o>T~;oCzT@L5d!JuAj&qrAU$vgab}^^ZVF-JjNSrVDXY@3CJaebytme}`-hzMh&I`qd?-NGYof%3-f0j@&4 zxLXn!ya##lI=giRRBmo?9i!8QFKaftAs;9JP zpn2u%LeH3f?#ZwikFXS?2eK-*i41e2j2+1{Ge@r80Y9S9{_-3ip13Ywog%UU*)m#E zr&==x^@oY!3RvS4*C{D;uCYAUy;}Q<;Mi*4AO+;JYMK@3b=nRB0Bn`CCfIrvFnVqH zyTp2MDk`oQC^h`s}B;`oNkKs|-vWg4$Pa*x0A z*Zb&f{h4v+ZCw@IR74q%KTkIh&ErI&Tb$z945Z-trYb~3-o$QpPKY6)9`UU^wp}1elg)6AL{&@ zxX_&?kFMhirjTjH{Cuiymow6DRh>VXk}11u#@=Il^yl1fJnf24js6(_Z-P1+U57ou zuL!{BgMtJX4{ij%#npZ^nJ-G4pYiUCwD~w`o_FoEsI&ew_#Pi?^iMS&(DiNoN>=fs z;knUpL9|qTg>s^%-ss834K!Ise7G>ueaX@b`=l4Vt(_8uoS}zW&S^mLvA}%B&L7=y zE-qC?*Y43+|EK+G6CT2z=byMUJdU8bcVEt`m{a&;tT4x)6F};8Rx;FY+mch0P3+9b zt$iHT7((>W5nxXZHy;Qt95DL=F5kQ!^jD5sNRp$;#VS$QW5#Yk#BX|fANjCGH!rq2^0?k3Z`b` zyJl}QmCjKHM7@aaqv+e}p0Td%c6Cf?h7v-M0E4>ek z+}{WSg(8ze-dt@e8sz1?db{}0RzONVCyIjDM25suGJqyKFM1{;2Q^)*-IEK6z3Ihx zpdM?PU0Ec&a?iD2FI~@u7h$BR7T71xhDzn&ZI*`T)GhomBD7`1XN7S+JP1=H!>mYA zQklUV6#Yzm(N*zIJGE>@cFyHpj$1foP^H%?K0lM#(*GRhZnW(GrXZyg17Fzx@cW+1%YQaEz3{nvD=DKYPuBJMA0J)4+1DJL5mtTMu$?a}K2&o%ce1B; zL6aR$N2sxh`kvt0n10c4L-EC1-!JcK_=NUmXPW{mNb-U{OkQ!b^9101>=<-3UBp(u zihR9|d1GE2RY^s3iuz{0In&;T8UH1vx+l9evPXEJm}KVo-6_s%q&T&702!O~9dg^j z{8YI<*hK>iz@;P?LQ?Qjbf;)uwCAcr9$LsU89W#idp9amUzTB}L464Jk1rY@&)zJl z$_E#|bn?QNBtvM$uk7w%nVnswNX&n3&)v1Dtfj*OKDgLRrYH`6>_m&IA_6I=Nm<7S;NaemX2ANyX?Rf#k4VN-<$RTdGW(Su}!;f$^lb zF4kQy8w4atX*ke@&Q|W<>51D0B_iqWefGGpfphAw3qZcM=d-aF43E;?1=|C#F!KG=+ zTY0J3)OK4^FQU7BLeOED&P*{KZMd5I)&cu?j5RE--g%}tokng{(xp05p8fm{U9?_; z1ir34*~wA7ivKwsRtyJhyn|TU0Rn-v^^)&~`%`|jUsQ)7OYFH~=tY@kBxl6$?&zSM(9^1FVW(%GA)Plgq4+Ke?4X?TFQAs^--OX3P6mQ$$Z=P8&WHu!;zFz}I zaTTohja-npx5L$XpJA9 z9Dk~~go7=*o>CK*qJ`3SD?jRy+vHt~JBHeAjAYNO_}|Sn_s&u7XScK&cS=>*#~PhZ zB3F&7_u&I9r^wYFUT`&l3bOM;uljfI%@y79Z81kIJBwcsu`Ag_w(y@Uk2+r`CU8SD5qHA;v9 zZN~vasaude;~=&eF`y=8@hFhT=m29Lr*59*g}Sns63YZP!7}PQhc|{pnn^(P>A`dE zLG4?QeAf|>BlG5XrP{yP{i!wZD>Fmg1&_YuH}W64qx$dTM%+`~T{i{OirLMpDRrDt zN&JJ+nY*9`@3yYbK~wU|IitrVn)SOP?Q8wJrRdxpgzq4vNY4vPxyA*srMPCwJM(ME zLX&mP3&Nj9ov`U)-8|h zjzG$f)A<}F;b@7mO4uv8jq)&wl%y@mF9S!wgQA}~6~1mEE;AFcn{vi9z%!Vi`X#u%C=RD-dUty*imr&C(S&XiMD5v; zJa%uqpK1<$6ql0;H$EbLw_u7pq~BGX(Mux8nt_Rw9~%9P1G70azy`DQ3@wARzExF1 z&4EbdjpccQFa`lL7UZP6<>w^hv_E%PU1(Ui@YK+=ebXay~!ahzAD5-Uf zjSZ2_-i6p`pcQJQH{@-D!nM%7anjqpmYnqG`p>CeHsN4&^(Nh z1KxiR1+XZ>2;lkw#!IA-8_kA4%5;7a&-j%^M%OcyE%zUmp6>TS!{02uVMOKd7~)6a zJdKSF6*{H{QBqqOu$rqQT+2A9q8H~_#I6F75Z;4EtP?x)J=ElCMz)Va=xo;?y938H zV`GH{8u9iiE%w83CtZXgSCrgAdrw9)h6JVNaG0bI{3VYlSEI4FzdbB>SKE67t+3C6vKDm}fjK$=L!(~+L_Kn>z0IA}eMcue3@gm(f|R)5l} zWmiaNNhLC80ct+3WRZ6)@VmE7zKIQm(nmN>%_ckdg3b--Zqk{1$OYGP2H8cTY!<+p zxxEwmh1X-1`YGCJf;!Cnrlo4Z*R4wAx}f55I^r&4W+ZXDXCRC-l5mJ zK3`}z;VY)2^mG=#nj5`K{wo4*$K{UfRV0S6I3RyW12vKZ^N!RuLSjG5gK!g`it&L0MOE0Q7~o)8 zRoB|Rt6A^v%VGuyk!fF(B57_*nwo>+9m^W|t^!4|*P{YpPexIS!1^%8QWgOu^{)tM z49$ua2|6kr9F23kG_#z9SGkg*x3df(knQ!t+Cdsz^94c9wNm_OT}w+$L-Rriw9q_$ zng>y9;fZSy+w@t|AgUO^JYmSjXJhYA-HLCNB*kyyOizAIkJ~eU&sKA+=NVx?>lJW$ z_aQwF{6NX1H&-M!vL5q~3J;$eii34&S<<_0AW3i?wQ||RVO(&=P_Gb2S4kuVs>UH~ zm?T+E6%npmdPsU^(umSxsai#?1Cm+=RuW1yP;H)RBaJ+bF4mYIY0hvubYTXc+w{w> z>Md(LRdxA?BA;UfohW2bcT&W>VhiGP*!TniGR?;h#aKnksw}x1yv|(X8doJ;lr>?f zJvp})@6(y6$e5TZ9EdBdtsTy}dHXK z3t~a)*f|Gi4xCJ+2+Vgcwds(wGV;JpW@lef7}Fy|`h;_4WPpdJ z>D^w9z*T1#RHQ^gF19vl;i3ee_HtJNeddvv28+?s>o@>u;Alfh;{-mA{BGk{gw#)e z&z&j#KAG?@T-LvNNF7*Hd|uGl>HGuO{!NAS*Fx#lwHYt+fB3rV-z6^pm9P8vCgqon zzv;#OKj}98|2UfmZ;Geiz`N~L^Cw>V&u{MY!*V(y;(@WILpNpKpIKoyJ#bPLW0N{h z?)KlGkn^Va!_WST@QakoHy)|)9PKYZh#qf=C^ajr(J^jmYZ)QWxF#%m*>|W_pCT-? zJp0tmR!Ji;xa5TAj91YVr#6WF45L!qK91vp>w>EwraH%ue;s?@N`CHo;Wz(R^P7zD z&(7E5gzv52OH_oC#HVxagjtXwt67hg=dQq0weofAw=wEtMEhI=$Lv>0WbzKd%nGEv z&^+PXL4gmw=MH`v0;rsuOd`kcv0Q$iBd>-2?0mcU;e5+NF{a18ZG>ZMZf@)9Dj9I# z*v8k6^>jeWycTe41a}eO2ZUL7GLEUM$b9OA zevy0B|H-FXqmcZ7<9+SjglYxJ6F70#z{0WHVdL+x8w0GrG2j;LfH1eHocLqP15?cD z2VUuzkJ@lr#Uctnm=%HP&8XSd`@SMP1-k0&qgT6B7i80@a8}jM=hKE`&aFdB3Rwbf zRTp7%v;Y$@S^etxufcCBr?s-}qY)=I^SI>9|_iD|Ak&3E(b3|-NsE$80TvhK8= zh4HaE<8#H{vWOlazT2`=lX)PhH|~6vY6>)oB?zfzkJ-4|%V{vS%xzZ(b+5Ufo8v17 zHcJCOF0gs9k)7Y}o>pw3JePr7gxKLsPL8L>q`j&5g~2`Nw<6_la?{LHfsn5VkUtFF zpB9s3s)!6jiQ9!0L%U8oT!-ith2F-HA>w1k2Xyf+UGh09@2G6%?W}?I%d;uCfS7yW z$raGDUgspBt1%p^6X`~%Sxa)jT4BdMuV*xGUDwcAH|5|G?ohh2OR)Oi+y4JPAgKSl ze|miQw4AWjG|FjuZ=k^F@_F3(D+1jTJ)Zi4?X6e`>}$txS{An^QNB?)qt2*mb3TFI zrs2$&AGc)v2XARLZh#K_dvj~{C%;Bqe>@2OAk+I*;{A!r1IBT zb8a8fcjxbSlzP8zeIcpuy%4Jz4Gjk>9cSp5+`cu`Nul{Yrr_s>ByQ}p8cBZP*p>vL zeHSJ919S22R}uT)W9`c8e&4aB_^ghtTJ<50Xj)ODP?b0#_M~)_wH*ga&sfMsW?px^Qh;nR`9Jm08YD(;QjQBRF>_ngdxUFWRm+Lt zF7hUMG!!mW(_rYjWM?kJdNq);{W{gFQVUJ+kjE;HqTSH2LsE<^Py_51cTKF>4Y^?Oax^v94@D$&`j8e>e~vvM$WqlN=`Mav~wwQ`C%P+h8cfjUNWe@Xd<7 zw;IL6IqEjr%R^!C8Z8F5ij$|W5n>;254zWtQ)$s9RXC(8CSP^`{z0hsP~w_jJOfv? z`+9bPkdzyze8gjh}|P1&re zSxK`NRf{oNo`4NK$bAo_aHWLLbEk`lAZiY%g?*gu8_}TfSgl%8Jo$Nfb-*S*SE<_3 zV>c9AJ;O5kkz#eiJo+g9Hws|=^`|5oGA!8sFMxo>b(uRuqsbBOq&H3@ECc$G!WU~i zy2_RzAud8`0W;NsNy))dC@RZR)PoJyiF_94dB(#o7BGpRtY)LIUWZtXI7-gL7?4C9 zQv^o%BOm~>sf_ZL*cUxT;jz+*B$cUW0kbZzc@D8<4r)}q0;6j|xlHD|FjOs=B|e*% zPY{+jt5*`J9BNk6YR71dFiz>&S^fC##`C8yjpMbuw7XdJ4~dN*MIa*DVMT|V%&F@$ ztP2aN)cKoipzF0lM0+Z4U>J#C5lS?T3616-V+^k23&N=|46%)c2^{H_K6q-q-YSWi z*H;F+QD$&b9mwXP$BfJ&ri?g-?i40p`A)ys&}05kp|bECMiAMSJ(I+-WTfdt-f5Uu z9J`7{^G2Gw3c>0_XPh-t*rkrAb4^mvGSPckf;wN^M<#RR>lR^^ofzi?x|CyHLP-O2 zHkS%RIp{6Zp7y>L`w94a%hC^%x;uOfEuuo1=FqVF#F(v4I0@ZQ_IR$GI5Zz%u0%-U zgXb`4I4Aq*`Zr^6>zE+Aw<00tbzN97$8%~`gm)eS>&&hwRG(aQGVGg0o`Hq>$?cM> zA`YTsYCWG+xoT>lNl-@uXtC9*y$QA6swv7!3tbA@%H2B}YKbjtgZV?lMl?CNK1wT? z2ynn(Le`c{krrqx)qyZjsUjlN$KBhGe?>U|!IC46?Y-X2iD-g4QfD1S)Qq0$tpwsw z_-w++Fw&-u;dpE45$uSn9XyV4RTpk0yS90Mb%$>a&c3)`V~l{X3FH-g6e5t_TC+sY z1)Bppx62~-8?$PAw1Y`*7czZ3O`ef&;+&6R<#XMgT(w{#}X(cblQyQ~u%IY6QJB!S0KnnbLI%O{!l5%^f z@#VtNF@#7&{dQBSvI?)@4n2dvxGs{IPsQ88jOLJ&<(OW_o^ifll<{d6dKib_>BKyuPWZS{T4blV_=T5VSFHB5HgV0f6hq2I zI%VAwxP$_5uoRNMQ0;n=Do}zFdK5J$)A}|xXx*|bBd8P^ZkpeD+0QvS=_={)c7Df> z+pND+SndsFBd^zj(ghEN?Pj;cWe%Kz?!koVQQSLwr3w1PJEs~wEqC?D@Xa#Px@l3j z$fR7-Gvz^ukc4E2USxQZ&R{ux=$wz7I<{3dp+TWL_0Vn=6U2SK<{rS=!~exhJT~+DP1oZ6m2d<85oizyVlIgQ*dic!%kcCEGtD4!!4RfVvfiBzF z$Jev3+!vCs>^gQ*9wZo)G3TlIjaW&-KhialWC#nbQ$blCfWgEuZ6@(bERYG)gSuAr z>9Z%FnhQy76OQcHt~9DxxYrrHOpmNrT$Zl$kBXM9ykgW}c4Sm`awihdf%&cKFIMN8 zs4zQ1qL_>0n(Yn&Pdk!$ktQeG6&?J{iRe&F2em}TOcWNrA{;;b2TAa+-loy-x%4oh z^6vu^*QjMNy2?QO<~qyuf;^CggL+S2&Nt^ zpx!cmXVQ2P4^C&e)>i7*!2B-a6z}knKU_fM5z6z52YnbvDMRw`-4l`P_*R_sd}C~l z5t?&83(s6C5olsPK(V&D_x(}$k-ffzs;;vk*+;S*l3e@d@jwdB3tiBhtTjyBd@%Tt z6o*%(z}xv3C5T+=kvgh*QIsR*2nPuwIPETK>yD^)uEWvOjv&?%s`lv=BC1lNuBSw% zmVrslBO5CClMa3vyK-s*MZ5Q~o!e9DjUq^CkxY?hpCqMdwGjrM5NF!LG&3f2+zLvX zCdTkiE1t;h-w`s+Vv6hYy(~soo-G|enS$JN(bl%?e_|!>o0O`jb69s%XtA$8lj0UZ zkMi6N2?;Ww#Hq8V^j4%0D@mHU*<`=p^@;$y#$YE*YnO3roA~vb0U?vC0lS;mx)dSb zDrp9JU568*3YTw1YSsO{U7YgOuLyC)>68nUU<)ufzQcKD&cc2^iAm1zD1jTPdC4p; z!;dbFW{mH2I&{u3nQ$*uTUH{6H$R_eI=Zo*)F&DJQ?n#?X!uRe1(esZbs`>bI&1_N zy?Sj0D<335TO$V~|G6m_d;Sq|NB$Q?x4=|7ew6O3&+(+Yc_uk6F#g zPzG_@#ys?#nII2?ts*rNXs?o!^V;&QoAvZ0pS`ol0vFVbt8SZc3tbR#)*>0ndILJR zj;9v6-BXBQGL}>pDSR_J>=LBjM?q^721AQu1DX!t+YKt-)A`_GJ`)~W)@v0n6%%g0 zOK{kb8F+gKo$0pfBYd}-WwfIC61C$xF(thwm=4dJeOa6j{p{Oh!xly{xW=(QZrN9a zb+!b3G}XPZs`j2;=E>Wg_>@S^mxII7%v=7%QT|=mv|1XzyV(t@s zuO)J$kmXL?`)`rR`B8%X_v_*M#DCjnLyzuuNZPHqQ`cxOND0n*uaQJa(DT~tywZ{# z>9S!>zWfx2--#j@YjM}MvbjgKdnGF9Q>5K#eAf(~iad1xvlC@o%}Y?THOoUv@>{7w zRFviY^Y07VKbLOXg$w85?D~{iAmbS6MD~jwV;3e$r+&0EY$odfo(%|BsrKazretCp zHKupb9ndGji?8JBV?o3n+-C)e_JgAq71Z@BX!-J80+MqcBqLEW5?w&_ zpFO*};;v`+J@wZA)mPssimB?E=jrM0dHU|V@4oKq>R~`=Sg)f!CchR&H)*NEl;*(R zFs|%kY1@0<$m{U@teB-T?M24W^?QCvo_xl1?vo9h8(U^JhP1(R=++DyY{bh4>Plug z>SPGb>z<5xL}bX%)89Vf7dw2zQfIldSk2zp#(g13gVDBSX(@!25lZJr^Wv1-Q<{yu zMaKXG_9#=CY<$evIb^c=)^vPxdQ+E*nt>Q7Nb$qI(?B}uI#(fy)cLh}=HQt`}U zB&P2GLoH-<3vsmPGo$2e7K)~7=tQfqJ}WeghaS-SHGt~pw`?{CgXzY|Z;gGzC22NS z+1p?ARC>5KG>bJHpPf^)64}WrfECt-XUFZ&Qh#{qSzI^#sV~Si;E3U$S@P*>z`@fL zjUl|-;PyH(FQIbvdHV#yrK$=Z3k40l-aHmjzO>7>ulU}-+w6Tq=s%|Xm(=w)6d1$d zoA1w0zv1q6Z(YS~{jkn_L(_w=efTJjkbe7yO?%GrJvQwRiuk9#zlz%bd^`I8UkhKc ze1A;y^A;BO)Ba@N&yMYaP+0snhD?XOe?5li?Q^eFzFVuCD!*T={}{vTA3m~wKZaLZ z_yr01;i!G)%c|bNkf~dKLQho{F zvxlW&d6QJRR+9@Jdr3PxYHUtch272TbjJ^7R8+<1+|ZQ8$GAxQ>Jz|Rij`cP0Km9m!iDhwRRq<<;TGI zs#>YvQ-s04*^ba}_V=poIOdt`_ho05mM9Oa%^%?ocCU+pvV5NWhz4%n)^?S1Oha!` zKx9o`B*ZkpwH_~Dig+Ez@jpo+_p7GK?ck|cHz{Aee7_$FjX>*(VB8#RZB0Y4)qaI~ zv!E>PJsPOMK4}izu|tTjbw1MjKIpl@Wg*7d^bp0ZB)Wc#X4;aqmfXchieT9`!%V`R9^R$+r{BY1vx>0^$;zaE7szZx zU8Od26pZB204&-6$jL|gijIl2sO~leJl~`LBC&5iXBR!Q_|`1R9|u^!S@VFexac3O zxkLn~ee{bSS~A&%$D5)|`r%oEwEVG)%~EGAbDgaAD7ma6Z>Yq*I*)X92wR1lXh@O* z6wSUtpRYafNvdLz{1)!j0V{qN?mb6W=5Q-@VmP{R*M^I&2TE?XSoOyHrD7*textkn zo98dP)yrey`7{Etjr)Ek^EHO7j zxq0q*>R?r~4r!V1&Wp_(m$;4J7@IYuD#18?#nGa$jXOh3W|I$LYIt;7D7;x-drQpZ z(T996Qy+E8k{243*xUKyKAjE=xnd|?>b3**-E8FR5%ip6mR=r_*e0`su~c=&`A>>y zY?Gt&Id=t7&HM2|oHgB2Cr46F)~aOZO7|A~J++0qaH={DU^bipcwrYn^HJB~K#?$6W&hWA-gU%UH8(z&IM;l*9&-!{_NhS|e@8()2hi$g9_x zia60@xeOJ-NAakHwIZB0ft6+ra<1Uug}9QmGufJTEiVlV@m-wp#(5*CMPM8sqxl~3 zTELsfWSSt>cxiPI)FMl>cqT2iL)IrdFW<+0G}w1<+OmUb+_ZAm+Sa73=4dowC52P4 zj#>H?%7I!&G*T2RY1?6uS=p7T`ohGQow`oiw4#Q{+(R}FGto@PMJR~HvU%^o8LL_tWa~tU)B(b!^H#NV(;sq6Jk^iI+fHX zYxdwY@&%JmPV4Qv)iJG*Rr9_W=;)+qZEkTZ1M6>Dfqe2yqjUJIo>)1JSuPR*z%#Mb zsbJUOsWkclN?P?bc`)rAdXH<*LL(h41rL(_WOR*tFwZ;63lpi2OKeup2%h7qaT7?F zl?f$xF4ECsjThD!+7|U^JvJ$ZY2)2^Y8)XH#TsVETBg5*TPq@a7CsK2TX{dL36?aH zlX9-b%%ZojIXDlPus}!}fsKy|R72w@-2p+)=1u#+k}U1~;%Q=YzZX1jKSIN{*RV5Z z-`fv^XkeOvcjrr z3BJ0r#ku^A`Fx}J0B9)0%RfDz;{x;uq#r$ws9Sp2?*3N3h)l-jT{;qRcy;AlBU7V# zE!#~42iPzW9OxXQ;I&GEI+f-y#01?`z8QjfD&})**Y@=k^f{AKh0HN!!Q6lg`RhlN z>R^Jh2~{;_=C(19>KP3dF#chlSYotDx@{<`w&52fD@*`S0iH#sC5bs@L}914;%nv3 zoTA6+Ik&DqHEtYCWZ$uua4G?6=|Py3pf?RKqMq~WPDR8bF_(lO^Z@wEab?qXpnSk= zBO1#E8$GQ~r26zaJ1Wb)jCX)%Eao=|?x0t}^n~$>wV8tm6I8YDk4bU9ha}tK6oV-O zH(hJxB{h`s-mt_*We<;KN-pX|veAPTqcQQ3?*qC7@hv{px`(jEK1FHE$$XH+8h_0X zagHQ@l|JZC>)NL)yT0GnH8xO0CiKa>K>e^bJ#*jSYrV$nXuu6Hz4?AC8hU5PH}%0fv%zkBJ{gP>0FnGynU!($hTt9InPDUE z8m+DBQE9pty(GJ>TBl8#k)<$bcby`QggRZgbwdYHldx;xier}g1oW{Vl_-woy0W@+ z*jm-xinSdv-{{9Y{5x~b?x}{Mawd@8RRS^U94JVbds*yB46wuu3q0#(S`5e~pJ74- zjiaao8C|*QwYS#_vLV~nmLxA@ec}iR8rP8Y;YN^h6^%%8mg3VI%vlUf1Fh`ndabGY= zo7QltB2q8a$HqUfc|ZD?Kg>mg?Vie~{pv*yzub+)RYNC0nb4i;w(YL^+~P`5*R?T# z&)3th$vx$2IoSU}hZ)jXM^=VY3y<<3{i%chgFkvV>RLcbY6*?MYXZy#7}Y!qAWVyL zpeH>Jo$C?Jp{%zCCQvDnR1Nd#kA?!_?7e$rFVZw_-W|u*@w@Q5{WVhfH@$dcB+Dr+ zaP=brh~fO@%iFAJ#(hCkNO zP0L_B$6P|3{v9w_h|2T_wYaHnSgQB7Inet5L3fSqja(?4hMFi})7hvQ^`KELHB5CxP%t*ZcYvI#x1&QxJ`HRBumtCpg z=a>EV$(5?F6n<~Pl?wi%>MQsB!xsF!b60Bn+ZOyqbN=d{pLgL(1y>4J?zvL9a@m!_ zmCOF3aHVrs3O{f4mCJr!xH6*uNo{|1&y~*oR=CoFzq;qQZvTGSUsUk(d;aQ^pI30@ zli$AdZ!7ruWxsuLrE}kP0qikHqIk_CJ&^ANS60F8^h3ZZmSfVVvz*K&15x?L_vr;| z>%+f%%^&|8qxHvG#}D>3Ql$Fd#p(ZuwGFfWUlZDz>Afw9cE@QgOqKT}sfifcW1dCC3qF%Jx020S7x#vLx*Iq)pZ{x(; zz`V~8=LS~gOQp3-%<}-A!E{$iX_lwJisaZBm4==Uk-&fua?F`UO-U_`AvLWobvYLO z-Q+axqlXMnqXv?tq^n#!rz|a0bc$Kq=6Fe(*mbV8j#-?zO;>GrXx^@BY+N1ayWTKU zYz=ZA58pCjNR`-+iRS};w)u);{?Sje0!Mxq`TBwsVsuy$wn0-0b&7pQLMoqw!>T0Z zKjtPaVOiNOSvdB`S3)Y$R5Z*pOHXFLmf%dMihu2O^~Zia>Mjy(R2<@%u$d&tYIEmhK> zBMJj7@;aR$F%`9kt3Be5Q+B222G}Ah7*MKqZTUZ4?;jl4Z$~ovpGM(Qj%K{7XxI&D% z+&a0tCQ2Wg)1C+1KdFskcFipWrqG5=c~xkcd=fWs1*I0S8bVfFaW>QJ%hPpL(Ru+q zl!CTQ9s}Nw_NBGd;ZkbDH}df-(##(`J`-q}84BMaM7s#W>VJcmMlF0w@v1glOi8nC zd2UkoZqBH(BCFEz0aA#haE#21V~vNAT&1&yuk9;5qsM`Fp~)-PZgro0IRCS%X>7BeWkL zimsKBL+Xa*j$dwZdjr`9sA=&kvkh(b5pDxqGtH7q_L{ITeco#WTWc$a#8$6_Y=BEjM-}_rBYB&)JP=YMb3gDYpF5HXQI+m@^q=7XCbWD#g zd(!$oG)ETiBzZGgAE2bmr9KHsnR-)YxyE7%X!YVj60nU8(U9^arKgbS0Q z78%B)Xqm8ZN9fKluGkOhKNPUD94W;5=swRPFB|M|aC@us<}1>u_WKX?VpPhz?p3|r zB0{Sygy)Z1jg146eDY>$j0~UOF9hpLeHu={CJ2So`W;5gmTglyF^emk7&CSiS%26q zOFD)7v8B8nB$atXraRhPe!|= z>)q#Ab6%57GetEfBc@}UDPRI<`&%scxSA{-KSEYk3rV|@*o-}17l5{$&~%Kqi%`Lt z_?ghl++3#Hw=$V1=tPP9SNtfhpULFrl#wRL;3z{lEYE;OM)vCK#|_>!jN%S7>kqgx zqb#T@lAaiy%s)Qkq?OX?svT{^KX$V8O73XYH{B}hH81b9sOMo*4?>pUM@@InF#G`b zvPon~>1IEoVRdG6U^`+1rDxt5{SNHc}I@>34b=F&s01Z_@4#v(t<7OjQ z8~--1|4SnSGIo(vmUDgQ3L(&uYZ9QOv_V?r&;tD814znr?Z7_a)rp;~%J2GOX;Kg_Xx(M64yI zZ=G-jNx;g6wp!3;+4K|3a%Qn0SV3amMci>YpuKB6_)g>x#xpxWq-gI$?QtaRYG(_c zn3xq&MRo zgp3UayrdSoT~0$>sFq_e(=#sQ%4MdWR-+bI#;wtPj@~q`4z1+OaTOv(H4fJfv-pa- z`fEX-?{2-7)mlE37>?casBU!uoGQNUeT_)cr@mub|M+R2y8nU;HKf)zNpnm^_|ABz zD(=~vN1R$bvN8mRGRmlpcri~oJZixxi$N0>F$NNO$(G30pX;h^o8xj<1C#*_}Q&MYgQOmJ7ccJRLFSIf>% zEikMa2{Y_xUDuG%#O-9T!y&bf7oQm_w?a!wd;UqAQY-PIkC&pc#ILpZk~gHO@NT&m z>!o|p^MH?F@watZE)-qWyhT7T1Ajp(=f3Lyw!o+T2p4<1i&3> z=u=924{qIu3itRDx2IevHq(vy-0c`D$Av9j{80vA(~t)-`ZLK(mgHbF-wb%@D&i0` z+UCP2Elw#5Qe}KAzEI$d%A|ei6VUA8YrKh(A+Dzc0KV|Gxw!I`Vu#@BkHo2&Ho0%{ zIPF`TdOtn1h-rGo1Uz}#L@vIGR`X`Wv1c)|VPWY3W?Zo^+|$31rA* zc)g{(hDZy^=kC~j?7b&m6M7aS+)uLF&jX|H@)+!YLWndIQZjZ;d95l2onXr(TV3i;cxp7v9++4+iO-A6PU{+*F zO%-3O&|_D4J#JPX6UE+a=GNY<7X|B^UuGf}PNo&r(bsa;FN8W);)`n#NOwZI4#0;k zCmz-K4r5)HnaXNY1!dQql(5iksMp8{oGhS3!= zK$fDDTdonfHcdog)WC0XqT6Goy^nWf#tgbFEUcLv!O*WFg(b-1?FhtI)=xq9t{Ib) zy&}~LG9U9RP2ws@J)@BXHntb_bMMFiYg_09}cc_NvzV}3+~X7=Ji zY$ICZBkEdjSh}eyRK-9JyP17$&6?2$_HUye5Q<2UGiR`j0cpb*Z40}eJSS0~v$a`b z^E4Ul5xB>87S~{kXGLsI7m-X^AI#U(=apqD_!O~jS(d|<+1T=7%F@Inn{|!PtXI`z z<161^7)b&M!0%ZkY3z6oIvm)4!Q;yAfDNdkfX^pZ$faD(JKu1+10r4}Lo*LNiREl^ zB3V8@OEGe$b*KOgCf}O4If=WjHV4UKEZQ>56vLP5=o?m74>PSa)hE8oZR6oTf8h3{ zA8!TWix`oaA6LZap?Y?PEOP0HNIg(*wSbJvTy{v5h({jLb?69g<`h&(6kziNQZ|c5 zSGLS0AHR$6EDVAT;JP*R+xE3!zn#I5Trv$1#GA@5QVFcbJ<|nEbS=N#Hwzk=a+*klO#17MoSdUmsQ+7UaG{PPu5^S%!+K1E*$U2tf#1r6Kxa zTZyr?vZ0=}*P72}*_@&X?my@{Fh8rXgQm*|A6yd9vid$3e0YAJrtpN@Stp9fq6KB^ zz#XI;{p<@;x)7G*!atvwqE)5-mQ|&m{+5j`+~lCjiRb-bU4F!c{juL%WKAk!P}`?@mn#!Z zc<~)_?T7tlZNJ498oqGPFASr((#_vzto^XxWIder4h+FM6hq^&9(8Q_`HuLi{reAE z_%HO|9|uoAZ{ctF#BXSfpSJKfeBw9sz)#2UH+Vx=zMk=%THo`tFc>y{A5w|JtPU0rmk~)$_1`aQAdy$xn(U zE6bG2Mpl#vOvRmTfnP#@Nz&`?5btOh5~Kc1``@eoJ0}0nJ|`p0dL!@WtpzMLrF3-& zqAbsM?xayJy~>Hn&G0%;a6hqi$fCQ@6Ckcb0LF#QcSpY^bp6}ueTnZz*x>SO;M1ypr_&TouB`_mR**U{4x{S44GoQ zGUlTU@`b|$cJb|6B^AuH(3Kgf`$HXUJ3E(!-(i4vR^_WR z1APtZFBVc``!WgNiK!se<#99y>9msQZN2ms^}=d`6CbzT<*7;0k_5MN?o|B&Uo;`Dw>N___Z^FJl0)SKO-4N(>y+e|)=%2M9jyMN8b@X! z>-Nj3mA40BLYjO;TAKZ~5Z5>)u7DX=`*_Dv;u3i1oBDxm1p{(xe%~_9$}NEEX9(A; z_FpvcB4!ChEU&xb@26PJ6MZ-cjqWJ*SEBsST2X=CC@f~bJL91=7*JouZv)NySQrS~ zHqa#6fhLWly2ko;l-yC9oX|)yrmF?<%=^00CtTOvCs+ncIV|*E%_MM zz@^Ldg>}2PNl#N`S>FP#0y9N*loPj?DxXH!0rn-Gk^GNXc~>_t``#%Jzoq%F7O@~E67q^d!Y;;Ob$o}F-okzsCziXq_Z;&~5Kp)^4G@Ud+Vw0%l-iz{tEFzKSVc#4A6CC z2{L0fOzlcg)Kr-z6p`#is_-n^Q(rIDm+z9ZlJ_A8aVGY>iIzs0+{6FaxA{na%xNI4 z39Uf{*F9#@qA=-U@>6fGWS>fR@9d6FjJF7>XWIi<3|4~Ze?Maq%6FBIFZW?=MYQi zxLgK+pC_iR@$fVR^(wn07`kxoVd@@W-EL7gWb@-8C{>=t1atoxHuFym1Y7i{7h2`} zNx7Gdf(c@BCC7p72OgQ{DC@V@Z*q1yGV5a;=H*2nE9A;H(4QBd60~*OW>ymRETL%^ zb1Z>Xk@OTs zW~aPEgZoKbF}CvhPvpm^_n(eQG7FBuyQ|a1ho`PWssEA9&JDN^U=d#1_=2RMVu2mU zEJ{_r5AL4uw?w}Ln~v5LYfSE8%fYm%$J+UYpTg&8Q_+&|F+O}8TV(7Vw{q=NBX!$LhpHo~9L^yZ9w+-WpDc(B8)|BZQW0J*Il1ZV-CdGi;|% zefBy&`A|q+49MFvCNX4nEJ>CXc37jwLfD_ra-2j6;VQ;4aW?6%^EVk0!E~)u@s^dd zi0}G>q|dMK7_!f<32u+_Mr&xiqX(raf6Z5@C@=k@kN#wE(4?bIJ1LT ziT0f^RCqU{8Ue{vjuAv2xjc2pARfk;AeTeu9F&Z&&Uywhz%bE2Dt6u!JbEZFdnkm6 zwrx|`JlAJP(-)B33SvH?pn}b9#WX(_ScsCCPWCx>=!-Zr$t}$fM3V^ym4-y}y@T4# zvEHA!^LeW|Az-273(|>&aj4ygwNLcaaRr1zbwIMQt{R9|^WMDdpPB z2N$>HHrg7YN?uIlhBzgfXf?W}pXK846GGX4i78!AKFN))3B)b8u53b^0OCkQ2YWPR6q47#M&mghs{W&c{@VW<}+%3U7o z+QsbFzs#5TyGc$KWXhjA{NZWZp6%!EQm87Marhs}7JqXyhRL`*Q1n$GD|UUcD7Bru z6Ne`SMay@bVCFe@^fQY+Zp%efB~yoWaGpH5?-wf z+xglr%xuK@bk@5Q0~)tK_WzN-@;6Qxy88mXhczvbZ$D^Tn2F5Y5>q3tff{}+oa8Zi z?;^SWQ?@a(W&{H!MEBHCucpTn-nOfu@?MjZ;>aWIpE`OZ9|-UFY}o0wA@k4Fd){7l zX0)dDWU>WKQSXb-eqqZsRRQ^J6)YQOYniZvO`GCFxR+m3rex7+S|r- zALLSc<=5pso7D8#we~!?Vk(GG25GI#*UZNs&F2>+Hlpvs<6n@jg~@lMnat{qL^pf{ z;PAT^rmkZp8FnI@fHW4T--LDC!$&!8GHY;kM_6v?t8Tjs&5Xs>L)H1w_ zdu*!r3lfNmWlw5nU~tw`>&V#N9k#K>f3GO|&2yjJ?BxTbFBsH%$et$48RUp+@!8@lH1kKV z?&@!VfMz;%!?qZ<9N?UajPf}hXIrVUd>?XN`IGH-c)1PQ>Y8sgWM8pV z(;Zd=7g_1>2Tx0W1#0#Qm_+5pJv2cg9PC}J?qd!bA2E2SK6ZOMpJ8zJ$&G}7UCc%% zFG?zC$ng^WqSC;0W6_@5vDW0dje)>Wvuvbt<4#*g#CzwLpvo}b)y&`p_2&V6L^^29 ze$=^je@UZaW{Siv0Owwc_~ zjWOcB^LQJvNRCD(bXr^~UV^G>&Ze>*4CzG=E6LED{5(j!x{V5y1a))8>b&FOP=38F zJ`qwF*^m&U++a#`JFYB#_JhYN_wP>&3wvaxu=;@;-l~!}Lsp4(x=ELYCB8KocbH=Yr0^dtYU=ol;r@QKqcF@hA z_J)tnK{p=1%8#y!(?|BwC2c-eVw|3(cQ9j*KUZSc0mvHxZU~BU3#Y{d$z2$o6avEb z%dsaDqew5c-umpi)znmmg%2{XOSMk(b15j^q<&Q^L2Z-nc36krP*7{k$=lT&TLeX2 z%GBW)keH-}%{Utk#l)d@=7LDlACPn=lJkDP&~qMn0V0IF7<@K(sOxJ20@d#;bz%$k zn2(}^Ao+Z;F$!bE5w;{~t9Qth6{#sAWvdGF?q$LT9d290?v@p03Pk$Qd1}nvL_<%O z5|Y8k!v_sEnKCRV+{U21cgH>>qfsRg9$Gp!sRi!8*~&jj?+4O{6(iEZhSh9_wNCIS z(=LCN^vSZJv9I+thV`$Jwqz#A}&L&%Nj>Pf5XktEWA#6SV0ws@&HvGs1Q&HG0kuG6!7veT_f0{bR=j z%@;iyLQ(KWvw)gT-#bzfX6kS#9NG)x4x~xrzAK2WBr7h+<@(y-M1kcHtw-od)?E1| zD8H%@>=|cyKH51h%w$jcw3I(Wkrjo5dk_ecfTM3!bkA7mgdDtX{-|kHRv?mBFH2oc zxG7- zEZIm*uwzcgIjQ|5V>=}Scx$~lC#(i_JBKQEI%EJf{ZzhkTDut7MaN{z%@h&qzmQ+v zS9N=7J72kS-g-2YNuGiz_G5c>nSjq4+X*iq0-BI)2_1uQh(=@0u5O*NVK!>DJEZ0< zWGc7_kmYy|NKaQz>57%bEdfv@68RD;6dqVK@5JO3*wBGx2UcU#!?K$9eua(_{uw%2 zDq#D@n6&{7DqECk>+)ocewd{?#K7tD{7A>8)~LYN9=gUDA$Wh1=ngM{_l6`n?-&kr zlpGWbo9mO?{m9htAP!2-8K@B*SqKCZ>9TCF@oMGM%`YY7x@`J*GmT2P5M`!BZEaPb z9G{4#-CJTTmm#WGwyV{@_A)60bBQ~#F}=OsLNU04b|~k)eM5!R+%JyYT|Vz;++A|B zZ^pz_>sKN2R|3pZ&xrUMr8C9Wif8vZUKAmh==mUcQEQ;5#ny`>u;q)FT58b&% zT+CMeK{HyiO1Og>nY57!^+0sUCtgE78KYw@Hohtr=67P(mPPl(d(Ad47b)W5ORF*G zYrUZ=qR@iEj#0#!5L^liNRDxcAR#U-mZ)$iy(Q8y5^pCq=a$q*j8u=SWIxz~ z%$(I1BN`Q8oe-BuAjs!Pm?9j>=Qqt1IX@%&c(5xqBP%aZY$T%NFf;F?Wx>rLRg0j{ zD9&|{m|q=Rk;@Z{X!P+6u$<4Zu;<3rPO(j<#&u{tCvn{_=WzhS0=oYPGY)0mpyGp3 zPFY?t2vvc2+2cv72e4}W0&Bync2Zf93T&^I7PLOwPc-eE>-vd@YNT6aFfBP5*Q=ct zSRhEUjB302V9~1spbVbFH**jG*=m6Ac{<-8l_K0ky#PPW&{-B9mx04U?A$U!G|A{# z?fd_lX8okI*_^g*R7GK)&z>0pd2vUi+}vH-bDY3l*UX1u0BVTA5PcX()*AI#s-|&s zrliB7UQ>y>eYQjcA-_AWwN-)l@v(AcPpN=Q$krB!KC#`5nn2ICu4@#ok=D(?{XR#j z-1t|}sM%fwm?iB4Z2+=;m;(S~x5=SmCAN{XTX&_igmp~-{_Jql$KZVXw&s}3>cQN* zR9T00f!?ri{S6vEMn=|mhXKO_*#|{_9=hZj5`rABE$&wYwM<$>Wag2BV34gP&%N9i zCdoDrBGn=AJi5IbyhrY;H*R*g@fBq!?TB)TSu~8{-JmznBo~nLQD;qX+9L)};FQj%0F{>Cod#7j97{uAnR6CHP8|$U_voq3RX+t~5SV`^&9M^FrOSl&GGY}SnnCe`)^E+FgAsjgSN6;->KlKHp>x*v20hvynD>n|R; zvWgm{Q^-1vO^=R_Y7vrR(u|tSS`9AyS;dCxg-@H=Iox~QE(v&Kc|1xg=3<417ISG` z^aRRStnx(32bj0N?xDS1qr48Djoiq#vUMdI$ZCw_1BUK0HK3Pr0(8s71;adAjg3-7 z7Zf`tq-i!Y?z`ZdWub&<`euB}8pS7+BXif2uhy5re1;GUoWp zK;1vwW>$5{Mg2=W+1X>kL&@D;QwxtzNh3vWXLju)V%tTn+-ffOJ5}NzP{bbsR^Q<{ ze*i08ZQl2I&L0j{*bkTf8qfLTneC^#@A2$^;5q*$ruL^ME`DXr`kQYi|He=Dcb)#f zsz#1~Ki=PZZHE0rXcAoy44?e(-(QXZB69L|#vuKRTr|}GtA<7Um3jIFsdZBS9KoV@ zzEe|^VfUwK6xKJ|wa_b3rgM=+6Z1tEJs|{Yh~sJ)N%$+5T^Gfb0MTBD6pDI6T+a+F7z@A=Y&JQt{mai0PCRdMt!VGr4AW?3TQt!s^(7g|L>wo

5~yF zUDCV$b>kW4|Al{sYek1Hbikt1aT&Kjm6iN7boP`0>sS8={)^H#rK+drD+!IcK9Bxy z{OgHQo^$nfYtm2cvTCH)s7>ny;9Q9hBKRBs_+iBi#lK4~+erK5W312lmfmnzJ~nJy zkZW*$F`$C#L(rzgyR@;(EcrKVo%R_$!lv6yC~l}|L@X-M)}RuXE&e5=tJ6YaP1eleG{eoL~H=GZA?U28G<7`6xw9RJk<#k z9N0;w zA*KeurHtP=lncV4LB##8{TlbvOG(f{>&~l`=BSW?r_{&pBb<_X9SN@|A-&55W5}O) zCXRWJ4>c!R9qx3}SjXboS5-kO2wveB6j4W1D(FPy-M=>^cD>0Yq9GY-37bf`#iF!x z6A_1;E-pDOhGV`iU8e$S(CON`B5CVN~r><rTWs#xaXh#D}i=$eu$ zFGtLs#zPqq(xufW9++jJD3XCYZ;KcmwYPgmJ0A_`$IBCWf`=4l^

Note: AppimageLauncher is required!

- + AppImage's lacking stability led to it's temporal removal. More information at https://github.com/KRTirtho/spotube/issues/1082 Debian/Ubuntu From ca546bef172daf79599a876a9481cae6414df55d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 17:26:05 +0600 Subject: [PATCH 037/261] chore(deps): bump KSXGitHub/github-actions-deploy-aur (#1372) Bumps [KSXGitHub/github-actions-deploy-aur](https://github.com/ksxgithub/github-actions-deploy-aur) from 2.7.0 to 2.7.1. - [Release notes](https://github.com/ksxgithub/github-actions-deploy-aur/releases) - [Commits](https://github.com/ksxgithub/github-actions-deploy-aur/compare/v2.7.0...v2.7.1) --- updated-dependencies: - dependency-name: KSXGitHub/github-actions-deploy-aur dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/spotube-publish-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 12a2f99b..805a89ac 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -66,7 +66,7 @@ jobs: - name: Release to AUR if: ${{ !inputs.dry_run }} - uses: KSXGitHub/github-actions-deploy-aur@v2.7.0 + uses: KSXGitHub/github-actions-deploy-aur@v2.7.1 with: pkgname: spotube-bin pkgbuild: aur-struct/PKGBUILD From beafe23e30479443718504d5588d33a36cd1fe0e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 17:26:32 +0600 Subject: [PATCH 038/261] chore(deps): bump build_runner from 2.4.6 to 2.4.9 (#1361) Bumps [build_runner](https://github.com/dart-lang/build) from 2.4.6 to 2.4.9. - [Release notes](https://github.com/dart-lang/build/releases) - [Commits](https://github.com/dart-lang/build/compare/build_runner-v2.4.6...build_runner-v2.4.9) --- updated-dependencies: - dependency-name: build_runner dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 588aca13..6bcef11e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -277,10 +277,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" + sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.4.9" build_runner_core: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 298631d2..274076ce 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -131,7 +131,7 @@ dependencies: lrc: ^1.0.2 dev_dependencies: - build_runner: ^2.3.2 + build_runner: ^2.4.9 envied_generator: ^0.3.0+3 flutter_distributor: ^0.0.2 flutter_gen_runner: ^5.1.0+1 From 27604b28f23c4c77401379d29a5fe9ea9796b6a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 17:27:03 +0600 Subject: [PATCH 039/261] chore(deps): bump popover from 0.2.8+2 to 0.3.0 (#1273) Bumps [popover](https://github.com/minikin/popover) from 0.2.8+2 to 0.3.0. - [Release notes](https://github.com/minikin/popover/releases) - [Changelog](https://github.com/minikin/popover/blob/main/CHANGELOG.md) - [Commits](https://github.com/minikin/popover/compare/v0.2.8...v0.3.0) --- updated-dependencies: - dependency-name: popover dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 6bcef11e..0ecd19f6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1796,10 +1796,10 @@ packages: dependency: "direct main" description: name: popover - sha256: "59f4a55ebb484d012c8aaa273ad58eee571945231b71fb938c5a69f63b5a94d4" + sha256: ca3bef9d88ebf5c5c3823946a5de3ce8360018fbb6a3e25819586a7d5a203db2 url: "https://pub.dev" source: hosted - version: "0.2.8+2" + version: "0.3.0" process: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 274076ce..db7ae18d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,7 +76,7 @@ dependencies: piped_client: git: url: https://github.com/KRTirtho/piped_client.git - popover: ^0.2.6+3 + popover: ^0.3.0 scrobblenaut: git: url: https://github.com/KRTirtho/scrobblenaut.git From 4b757d8e8def0572b55d5ebccfb01cedd189b604 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 17:27:42 +0600 Subject: [PATCH 040/261] chore(deps): bump flutter_gen_runner from 5.3.1 to 5.4.0 (#1272) Bumps [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) from 5.3.1 to 5.4.0. - [Release notes](https://github.com/FlutterGen/flutter_gen/releases) - [Changelog](https://github.com/FlutterGen/flutter_gen/blob/main/CHANGELOG.md) - [Commits](https://github.com/FlutterGen/flutter_gen/compare/v5.3.1...v5.4.0) --- updated-dependencies: - dependency-name: flutter_gen_runner dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pubspec.lock | 8 ++++---- pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 0ecd19f6..22236fe8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -810,18 +810,18 @@ packages: dependency: transitive description: name: flutter_gen_core - sha256: e8637dd6a59860f89e5e71be0a27101ec32dad1a0ed7fd879fd23b6e91d5004d + sha256: "3a6c3dbc1c0e260088e9c7ed1ba905436844e8c01a44799f6281edada9e45308" url: "https://pub.dev" source: hosted - version: "5.3.1" + version: "5.4.0" flutter_gen_runner: dependency: "direct dev" description: name: flutter_gen_runner - sha256: "7de1bf4fc0439be0fef3178b6423d5c7f1f9f3a38a7c6fafe75d7f70ff4856d7" + sha256: "24889d5140b03997f7148066a9c5fab8b606dff36093434c782d7a7fb22c6fb6" url: "https://pub.dev" source: hosted - version: "5.3.1" + version: "5.4.0" flutter_hooks: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index db7ae18d..9e573f35 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -134,7 +134,7 @@ dev_dependencies: build_runner: ^2.4.9 envied_generator: ^0.3.0+3 flutter_distributor: ^0.0.2 - flutter_gen_runner: ^5.1.0+1 + flutter_gen_runner: ^5.4.0 flutter_launcher_icons: ^0.13.1 flutter_lints: ^3.0.1 flutter_test: From 17837f41499b78563fbd2a25981d73fddf7d6e0a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 17:29:45 +0600 Subject: [PATCH 041/261] chore(deps): bump flutter_hooks from 0.20.1 to 0.20.5 (#1271) Bumps [flutter_hooks](https://github.com/rrousselGit/flutter_hooks/tree/master/packages) from 0.20.1 to 0.20.5. - [Commits](https://github.com/rrousselGit/flutter_hooks/commits/flutter_hooks-v0.20.5/packages) --- updated-dependencies: - dependency-name: flutter_hooks dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 22236fe8..5793c484 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -826,10 +826,10 @@ packages: dependency: "direct main" description: name: flutter_hooks - sha256: "6ae13b1145c589112cbd5c4fda6c65908993a9cb18d4f82042e9c28dd9fbf611" + sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 url: "https://pub.dev" source: hosted - version: "0.20.1" + version: "0.20.5" flutter_inappwebview: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 9e573f35..153d0f07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,7 +42,7 @@ dependencies: ref: 1f0bec3283626dcbd8ee2f54e238d096d8dea50e flutter_displaymode: ^0.6.0 flutter_feather_icons: ^2.0.0+1 - flutter_hooks: ^0.20.0 + flutter_hooks: ^0.20.5 flutter_inappwebview: ^6.0.0 flutter_localizations: sdk: flutter From b948872258a42022af25603e382c40309b500421 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 17:30:11 +0600 Subject: [PATCH 042/261] chore(deps): bump cached_network_image from 3.3.0 to 3.3.1 (#1270) Bumps [cached_network_image](https://github.com/Baseflow/flutter_cached_network_image) from 3.3.0 to 3.3.1. - [Commits](https://github.com/Baseflow/flutter_cached_network_image/compare/v3.3.0...v3.3.1) --- updated-dependencies: - dependency-name: cached_network_image dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pubspec.lock | 12 ++++++------ pubspec.yaml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 5793c484..b4e38b7f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -317,26 +317,26 @@ packages: dependency: "direct main" description: name: cached_network_image - sha256: f98972704692ba679db144261172a8e20feb145636c617af0eb4022132a6797f + sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f" url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "3.3.1" cached_network_image_platform_interface: dependency: transitive description: name: cached_network_image_platform_interface - sha256: "56aa42a7a01e3c9db8456d9f3f999931f1e05535b5a424271e9a38cabf066613" + sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" cached_network_image_web: dependency: transitive description: name: cached_network_image_web - sha256: "759b9a9f8f6ccbb66c185df805fac107f05730b1dab9c64626d1008cca532257" + sha256: "42a835caa27c220d1294311ac409a43361088625a4f23c820b006dd9bffb3316" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" catcher_2: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 153d0f07..a9b4ed62 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: audio_session: ^0.1.18 auto_size_text: ^3.0.0 buttons_tabbar: ^1.3.6 - cached_network_image: ^3.3.0 + cached_network_image: ^3.3.1 catcher_2: 1.0.0 collection: ^1.15.0 cupertino_icons: ^1.0.5 From 22a49e56a2397791da735356a1173e741966c376 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 11 Apr 2024 17:56:41 +0600 Subject: [PATCH 043/261] refactor: use tcp server based track matcher (#1386) * refactor: remove SourcedTrack based audio player and utilize mediakit playback system * feat: implement local (loopback) server to resolve stream source and leverage the media_kit playback API * feat: add source change support and re-add prefetching tracks * fix: assign lastId when track fetch completes regardless of error * chore: remove print statements * fix: remote queue not working * fix: increase mpv network timeout to reduce auto-skipping * fix: do not pre-fetch local tracks * fix(proxy-playlist): reset collections on load * chore: fix lint warnings * fix(mobile): player overlay should not be visible when the player is not playing * chore: fix typo in turkish translation * cd: checkout PR branch * cd: upgrade flutter version * chore: fix lint errors --- .github/workflows/pr-lint.yml | 4 +- .vscode/settings.json | 4 + analysis_options.yaml | 8 +- bin/translated_messages.dart | 2 + bin/untranslated_messages.dart | 3 +- lib/components/album/album_card.dart | 2 +- lib/components/library/user_local_tracks.dart | 4 +- lib/components/player/player.dart | 1 + lib/components/player/player_overlay.dart | 5 +- lib/components/player/player_queue.dart | 13 +- .../player/sibling_tracks_sheet.dart | 44 +- lib/components/playlist/playlist_card.dart | 2 +- lib/components/root/bottom_player.dart | 2 - lib/components/shared/bordered_text.dart | 2 +- .../shared/page_window_title_bar.dart | 1 + lib/components/shared/panels/controller.dart | 2 +- lib/components/shared/panels/helpers.dart | 2 +- .../shared/track_tile/track_tile.dart | 2 +- .../sections/header/header_buttons.dart | 4 + lib/extensions/track.dart | 7 + .../configurators/use_close_behavior.dart | 1 + .../utils/use_custom_status_bar_color.dart | 3 + lib/hooks/utils/use_force_update.dart | 1 + lib/l10n/app_th.arb | 2 +- lib/l10n/app_tr.arb | 2 +- lib/l10n/l10n.dart | 4 +- lib/main.dart | 2 + lib/pages/connect/control/control.dart | 54 +- lib/pages/lyrics/synced_lyrics.dart | 4 - lib/pages/root/root_app.dart | 1 + lib/provider/authentication_provider.dart | 2 +- lib/provider/connect/clients.dart | 8 +- lib/provider/connect/connect.dart | 14 +- .../proxy_playlist/next_fetcher_mixin.dart | 108 --- .../proxy_playlist/player_listeners.dart | 104 +-- .../proxy_playlist/proxy_playlist.dart | 15 +- .../proxy_playlist_provider.dart | 212 +---- .../proxy_playlist/skip_segments.dart | 10 +- lib/provider/server/active_sourced_track.dart | 47 ++ lib/provider/server/server.dart | 119 +++ lib/provider/server/sourced_track.dart | 28 + lib/services/audio_player/audio_player.dart | 49 +- .../audio_player/audio_player_impl.dart | 233 +----- .../audio_players_streams_mixin.dart | 10 +- lib/services/audio_player/custom_player.dart | 143 ++++ .../audio_player/mk_state_player.dart | 382 --------- .../audio_services/linux_audio_service.dart | 736 ------------------ .../audio_services/mobile_audio_service.dart | 1 + .../audio_services/smtc_windows_web.dart | 2 + lib/services/cli/cli.dart | 2 + .../download_manager/download_task.dart | 2 - lib/utils/duration.dart | 2 - pubspec.lock | 4 +- pubspec.yaml | 4 +- untranslated_messages.json | 3 +- 55 files changed, 590 insertions(+), 1838 deletions(-) delete mode 100644 lib/provider/proxy_playlist/next_fetcher_mixin.dart create mode 100644 lib/provider/server/active_sourced_track.dart create mode 100644 lib/provider/server/server.dart create mode 100644 lib/provider/server/sourced_track.dart create mode 100644 lib/services/audio_player/custom_player.dart delete mode 100644 lib/services/audio_player/mk_state_player.dart delete mode 100644 lib/services/audio_services/linux_audio_service.dart diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index e4fb55c5..156d1a07 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,13 +4,15 @@ on: pull_request: env: - FLUTTER_VERSION: '3.16.0' + FLUTTER_VERSION: '3.19.5' jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} - uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} diff --git a/.vscode/settings.json b/.vscode/settings.json index 462d33ef..29c5ba4e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,15 +2,19 @@ "cmake.configureOnOpen": false, "cSpell.words": [ "acousticness", + "ambiguate", "Amoled", "Buildless", "danceability", "fuzzywuzzy", + "gapless", "instrumentalness", "Mpris", + "RGBO", "riverpod", "Scrobblenaut", "skeletonizer", + "songlink", "speechiness", "Spotube", "winget" diff --git a/analysis_options.yaml b/analysis_options.yaml index 4ba476e0..d5b904cc 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -30,10 +30,12 @@ linter: # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options analyzer: - enable-experiment: - - records - - patterns errors: invalid_annotation_target: ignore plugins: - custom_lint + exclude: + - "**.freezed.dart" + - "**.g.dart" + - "**.gr.dart" + - "**/generated_plugin_registrant.dart" diff --git a/bin/translated_messages.dart b/bin/translated_messages.dart index 0de398df..1ac8f148 100644 --- a/bin/translated_messages.dart +++ b/bin/translated_messages.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:convert'; import 'dart:io'; diff --git a/bin/untranslated_messages.dart b/bin/untranslated_messages.dart index e19f9a07..0b3485a7 100644 --- a/bin/untranslated_messages.dart +++ b/bin/untranslated_messages.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:convert'; import 'dart:io'; @@ -40,7 +42,6 @@ void main(List args) { "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( args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues, diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 678bfd06..ef831d27 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -73,7 +73,7 @@ class AlbumCard extends HookConsumerWidget { final fetchedTracks = await fetchAllTrack(); - if (fetchedTracks.isEmpty) return; + if (fetchedTracks.isEmpty || !context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 778558f6..6a953385 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -28,6 +28,7 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/service_utils.dart'; +// ignore: depend_on_referenced_packages import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; const supportedAudioTypes = [ @@ -185,9 +186,6 @@ class UserLocalTracks extends HookConsumerWidget { ref, trackSnapshot.asData!.value, ); - } else { - // TODO: Remove stop capability - // playlistNotifier.stop(); } } } diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 6dbd9b11..054e6706 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -96,6 +96,7 @@ class PlayerView extends HookConsumerWidget { final topPadding = MediaQueryData.fromView(View.of(context)).padding.top; + // ignore: deprecated_member_use return WillPopScope( onWillPop: () async { await panelController.close(); diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index e2ca9674..37ae49cf 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -24,11 +24,10 @@ class PlayerOverlay extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final canShow = ref.watch( - ProxyPlaylistNotifier.provider.select((s) => s.active != null), - ); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final canShow = playlist.activeTrack != null; + final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 0bf61da4..914d7bc9 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -53,8 +53,7 @@ class PlayerQueue extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final mediaQuery = MediaQuery.of(context); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final controller = useAutoScrollController(); final searchText = useState(''); @@ -161,7 +160,7 @@ class PlayerQueue extends HookConsumerWidget { snap: false, backgroundColor: Colors.transparent, elevation: 0, - automaticallyImplyLeading: !isSearching.value, + automaticallyImplyLeading: false, title: BackdropFilter( filter: ImageFilter.blur( sigmaX: 10, @@ -241,7 +240,7 @@ class PlayerQueue extends HookConsumerWidget { ], ), onPressed: () { - playlistNotifier.stop(); + onStop(); Navigator.of(context).pop(); }, ), @@ -251,9 +250,7 @@ class PlayerQueue extends HookConsumerWidget { ), const SliverGap(10), SliverReorderableList( - onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); - }, + onReorder: onReorder, itemCount: filteredTracks.length, onReorderStart: (index) { HapticFeedback.selectionClick(); @@ -277,7 +274,7 @@ class PlayerQueue extends HookConsumerWidget { if (playlist.activeTrack?.id == track.id) { return; } - await playlistNotifier.jumpToTrack(track); + await onJump(track); }, leadingActions: [ if (!isSearching.value && diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 99ab223f..eef34be6 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -4,7 +4,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -16,6 +15,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; @@ -53,21 +53,22 @@ class SiblingTracksSheet extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final preferences = ref.watch(userPreferencesProvider); final isSearching = useState(false); final searchMode = useState(preferences.searchMode); + final activeTrackNotifier = ref.watch(activeSourcedTrackProvider.notifier); + final activeTrack = + ref.watch(activeSourcedTrackProvider) ?? playlist.activeTrack; final title = ServiceUtils.getTitle( - playlist.activeTrack?.name ?? "", - artists: - playlist.activeTrack?.artists?.map((e) => e.name!).toList() ?? [], + activeTrack?.name ?? "", + artists: activeTrack?.artists?.map((e) => e.name!).toList() ?? [], onlyCleanArtist: true, ).trim(); final defaultSearchTerm = - "$title - ${playlist.activeTrack?.artists?.asString() ?? ""}"; + "$title - ${activeTrack?.artists?.asString() ?? ""}"; final searchController = useTextEditingController( text: defaultSearchTerm, ); @@ -91,8 +92,7 @@ class SiblingTracksSheet extends HookConsumerWidget { return siblingType.info; })); - final activeSourceInfo = - (playlist.activeTrack! as SourcedTrack).sourceInfo; + final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo; return results ..removeWhere((element) => element.id == activeSourceInfo.id) @@ -112,8 +112,7 @@ class SiblingTracksSheet extends HookConsumerWidget { return siblingType.info; }), ); - final activeSourceInfo = - (playlist.activeTrack! as SourcedTrack).sourceInfo; + final activeSourceInfo = (activeTrack! as SourcedTrack).sourceInfo; return searchResults ..removeWhere((element) => element.id == activeSourceInfo.id) ..insert( @@ -124,18 +123,18 @@ class SiblingTracksSheet extends HookConsumerWidget { }, [ searchTerm, searchMode.value, - playlist.activeTrack, + activeTrack, preferences.audioSource, ]); final siblings = useMemoized( () => playlist.isFetching == false ? [ - (playlist.activeTrack as SourcedTrack).sourceInfo, - ...(playlist.activeTrack as SourcedTrack).siblings, + (activeTrack as SourcedTrack).sourceInfo, + ...activeTrack.siblings, ] : [], - [playlist.isFetching, playlist.activeTrack], + [playlist.isFetching, activeTrack], ); final borderRadius = floating @@ -146,12 +145,11 @@ class SiblingTracksSheet extends HookConsumerWidget { ); useEffect(() { - if (playlist.activeTrack is SourcedTrack && - (playlist.activeTrack as SourcedTrack).siblings.isEmpty) { - playlistNotifier.populateSibling(); + if (activeTrack is SourcedTrack && activeTrack.siblings.isEmpty) { + activeTrackNotifier.populateSibling(); } return null; - }, [playlist.activeTrack]); + }, [activeTrack]); final itemBuilder = useCallback( (SourceInfo sourceInfo) { @@ -178,20 +176,18 @@ class SiblingTracksSheet extends HookConsumerWidget { ), enabled: playlist.isFetching != true, selected: playlist.isFetching != true && - sourceInfo.id == - (playlist.activeTrack as SourcedTrack).sourceInfo.id, + sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id, selectedTileColor: theme.popupMenuTheme.color, onTap: () { if (playlist.isFetching == false && - sourceInfo.id != - (playlist.activeTrack as SourcedTrack).sourceInfo.id) { - playlistNotifier.swapSibling(sourceInfo); + sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) { + activeTrackNotifier.swapSibling(sourceInfo); Navigator.of(context).pop(); } }, ); }, - [playlist.isFetching, playlist.activeTrack, siblings], + [playlist.isFetching, activeTrack, siblings], ); final mediaQuery = MediaQuery.of(context); diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index e5b87d6d..3777a1cb 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -72,7 +72,7 @@ class PlaylistCard extends HookConsumerWidget { List fetchedTracks = await fetchAllTracks(); - if (fetchedTracks.isEmpty) return; + if (fetchedTracks.isEmpty || !context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 19fa7c93..1cdf72b5 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -19,7 +19,6 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/connect/connect.dart' hide volumeProvider; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; @@ -36,7 +35,6 @@ class BottomPlayer extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - final remoteControl = ref.watch(connectProvider); final mediaQuery = MediaQuery.of(context); diff --git a/lib/components/shared/bordered_text.dart b/lib/components/shared/bordered_text.dart index 627b2a3c..f25f2208 100644 --- a/lib/components/shared/bordered_text.dart +++ b/lib/components/shared/bordered_text.dart @@ -79,7 +79,7 @@ class BorderedText extends StatelessWidget { strutStyle: child.strutStyle, textAlign: child.textAlign, textDirection: child.textDirection, - textScaleFactor: child.textScaleFactor, + textScaler: child.textScaler, ), child, ], diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index f956fa28..37daefa9 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -599,6 +599,7 @@ class MouseStateBuilder extends StatefulWidget { final VoidCallback? onPressed; const MouseStateBuilder({super.key, required this.builder, this.onPressed}); @override + // ignore: library_private_types_in_public_api _MouseStateBuilderState createState() => _MouseStateBuilderState(); } diff --git a/lib/components/shared/panels/controller.dart b/lib/components/shared/panels/controller.dart index a573c06c..65c2444e 100644 --- a/lib/components/shared/panels/controller.dart +++ b/lib/components/shared/panels/controller.dart @@ -1,4 +1,4 @@ -part of panels; +part of './sliding_up_panel.dart'; class PanelController extends ChangeNotifier { SlidingUpPanelState? _panelState; diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/shared/panels/helpers.dart index 7dad96d5..6d0dde31 100644 --- a/lib/components/shared/panels/helpers.dart +++ b/lib/components/shared/panels/helpers.dart @@ -1,4 +1,4 @@ -part of panels; +part of "./sliding_up_panel.dart"; /// if you want to prevent the panel from being dragged using the widget, /// wrap the widget with this diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 61061d24..5a075502 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -208,7 +208,7 @@ class TrackTile extends HookConsumerWidget { Expanded( flex: 4, child: switch (track.runtimeType) { - LocalTrack => Text( + LocalTrack() => Text( track.album!.name!, maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index f505f765..71e6c9f5 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -46,6 +46,8 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); + if (!context.mounted) return; + final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); @@ -76,6 +78,8 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); + if (!context.mounted) return; + final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index d8258a6d..9755179d 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -5,6 +5,7 @@ import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; extension TrackExtensions on Track { Track fromFile( @@ -90,3 +91,9 @@ extension TrackSimpleExtensions on TrackSimple { return track; } } + +extension TracksToMediaExtension on Iterable { + List asMediaList() { + return map((track) => SpotubeMedia(track)).toList(); + } +} diff --git a/lib/hooks/configurators/use_close_behavior.dart b/lib/hooks/configurators/use_close_behavior.dart index 05c03fff..79b14fa9 100644 --- a/lib/hooks/configurators/use_close_behavior.dart +++ b/lib/hooks/configurators/use_close_behavior.dart @@ -5,6 +5,7 @@ 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'; +// ignore: depend_on_referenced_packages import 'package:local_notifier/local_notifier.dart'; final closeNotification = DesktopTools.createNotification( diff --git a/lib/hooks/utils/use_custom_status_bar_color.dart b/lib/hooks/utils/use_custom_status_bar_color.dart index d1266fe2..7c5c7b27 100644 --- a/lib/hooks/utils/use_custom_status_bar_color.dart +++ b/lib/hooks/utils/use_custom_status_bar_color.dart @@ -19,11 +19,13 @@ void useCustomStatusBarColor( ), ); + // ignore: invalid_use_of_visible_for_testing_member final statusBarColor = SystemChrome.latestStyle?.statusBarColor; useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) { if (automaticSystemUiAdjustment != null) { + // ignore: deprecated_member_use WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = automaticSystemUiAdjustment; } @@ -43,6 +45,7 @@ void useCustomStatusBarColor( }); return () { if (automaticSystemUiAdjustment != null) { + // ignore: deprecated_member_use WidgetsBinding.instance.renderView.automaticSystemUiAdjustment = false; } }; diff --git a/lib/hooks/utils/use_force_update.dart b/lib/hooks/utils/use_force_update.dart index 74151a65..268f0f04 100644 --- a/lib/hooks/utils/use_force_update.dart +++ b/lib/hooks/utils/use_force_update.dart @@ -2,5 +2,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; void Function() useForceUpdate() { final state = useState(null); + // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member return () => state.notifyListeners(); } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 5df6bc20..cd58a20d 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -12,7 +12,7 @@ "new_releases": "เพิ่งปล่อยใหม่", "songs": "เพลง", "playing_track": "กำลังเล่น {track}", - "queue_clear_alert": "การดำเนินการนี้จะล้างคิวปัจจุบัน {track length} แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?", + "queue_clear_alert": "การดำเนินการนี้จะล้างคิวปัจจุบัน {track_length} แทร็ก จะถูกลบออก\nคุณต้องการดำเนินการต่อหรือไม่?", "load_more": "โหลดเพิ่มเติม", "playlists": "เพลย์ลิสต์", "artists": "ศิลปิน", diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index ee7562ef..a4050853 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -313,7 +313,7 @@ "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.", "contribute_on_github": "GitHub'a katkıda bulunun", "donate_on_open_collective": "Open Collective'e bağış yap", - "browse_anonymously": "Anonim Olarak Göz at" + "browse_anonymously": "Anonim Olarak Göz at", "enable_connect": "Bağlantıyı Etkinleştir", "enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin", "devices": "Cihazlar", diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 180d2ec6..e584d2be 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -13,6 +13,8 @@ /// sappho192@github => Korean /// watchakorn-18k@github => Thai +library l10n; + import 'package:flutter/material.dart'; class L10n { @@ -40,4 +42,4 @@ class L10n { const Locale('zh', 'CN'), const Locale('vi', 'VN'), ]; -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index 8de524c7..d6df20ea 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,6 +26,7 @@ import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/palette_provider.dart'; +import 'package:spotube/provider/server/server.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; @@ -182,6 +183,7 @@ class SpotubeState extends ConsumerState { ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); final router = ref.watch(routerProvider); + ref.listen(playbackServerProvider, (_, __) {}); ref.listen(connectServerProvider, (_, __) {}); ref.listen(connectClientsProvider, (_, __) {}); diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index 16256568..b78f0ed3 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -18,6 +18,33 @@ import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/utils/service_utils.dart'; +class RemotePlayerQueue extends ConsumerWidget { + const RemotePlayerQueue({super.key}); + + @override + Widget build(BuildContext context, ref) { + final connectNotifier = ref.watch(connectProvider.notifier); + final playlist = ref.watch(queueProvider); + return PlayerQueue( + playlist: playlist, + floating: true, + onJump: (track) async { + final index = playlist.tracks.toList().indexOf(track); + connectNotifier.jumpTo(index); + }, + onRemove: (track) async { + await connectNotifier.removeTrack(track); + }, + onStop: () async => connectNotifier.stop(), + onReorder: (oldIndex, newIndex) async { + await connectNotifier.reorder( + (oldIndex: oldIndex, newIndex: newIndex), + ); + }, + ); + } +} + class ConnectControlPage extends HookConsumerWidget { const ConnectControlPage({super.key}); @@ -50,27 +77,6 @@ class ConnectControlPage extends HookConsumerWidget { minimumSize: const Size(28, 28), ); - final playerQueue = Consumer(builder: (context, ref, _) { - final playlist = ref.watch(queueProvider); - return PlayerQueue( - playlist: playlist, - floating: true, - onJump: (track) async { - final index = playlist.tracks.toList().indexOf(track); - connectNotifier.jumpTo(index); - }, - onRemove: (track) async { - await connectNotifier.removeTrack(track); - }, - onStop: () async => connectNotifier.stop(), - onReorder: (oldIndex, newIndex) async { - await connectNotifier.reorder( - (oldIndex: oldIndex, newIndex: newIndex), - ); - }, - ); - }); - ref.listen(connectClientsProvider, (prev, next) { if (next.asData?.value.resolvedService == null) { context.pop(); @@ -292,7 +298,7 @@ class ConnectControlPage extends HookConsumerWidget { showModalBottomSheet( context: context, builder: (context) { - return playerQueue; + return const RemotePlayerQueue(); }, ); }, @@ -304,8 +310,8 @@ class ConnectControlPage extends HookConsumerWidget { ), if (constrains.lgAndUp) ...[ const VerticalDivider(thickness: 1), - Expanded( - child: playerQueue, + const Expanded( + child: RemotePlayerQueue(), ), ] ], diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 52824f5e..3b158d47 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,8 +1,4 @@ -import 'dart:ui'; - -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 2e079200..6ce74e53 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -190,6 +190,7 @@ class RootApp extends HookConsumerWidget { } } + // ignore: deprecated_member_use return WillPopScope( onWillPop: () async { if (rootPaths[location] != 0) { diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index f1cf58ec..0258058b 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -131,7 +131,7 @@ class AuthenticationNotifier Future logout() async { state = null; if (kIsMobile) { - WebStorageManager.instance().android.deleteAllData(); + WebStorageManager.instance().deleteAllData(); CookieManager.instance().deleteAllCookies(); } } diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart index 282c96aa..d92ff8d3 100644 --- a/lib/provider/connect/clients.dart +++ b/lib/provider/connect/clients.dart @@ -64,10 +64,10 @@ class ConnectClientsNotifier extends AsyncNotifier { .where((s) => s.name != event.service!.name) .toList(), discovery: state.value!.discovery, - resolvedService: - event.service?.name == state.value!.resolvedService!.name - ? null - : state.value!.resolvedService, + resolvedService: state.value?.resolvedService != null && + event.service?.name == state.value?.resolvedService?.name + ? null + : state.value!.resolvedService, ), ); break; diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart index 65daaf55..6360c750 100644 --- a/lib/provider/connect/connect.dart +++ b/lib/provider/connect/connect.dart @@ -4,6 +4,7 @@ import 'package:catcher_2/catcher_2.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; @@ -38,19 +39,21 @@ final volumeProvider = StateProvider( (ref) => 1.0, ); +final logger = getLogger('ConnectNotifier'); + class ConnectNotifier extends AsyncNotifier { @override build() async { try { final connectClients = ref.watch(connectClientsProvider); - print('Building ConnectNotifier'); if (connectClients.asData?.value.resolvedService == null) return null; final service = connectClients.asData!.value.resolvedService!; - print( - 'Connecting to ${service.name}: ws://${service.host}:${service.port}/ws'); + logger.t( + '♾️ Connecting to ${service.name}: ws://${service.host}:${service.port}/ws', + ); final channel = WebSocketChannel.connect( Uri.parse('ws://${service.host}:${service.port}/ws'), @@ -58,8 +61,9 @@ class ConnectNotifier extends AsyncNotifier { await channel.ready; - print( - 'Connected to ${service.name}: ws://${service.host}:${service.port}/ws'); + logger.t( + '✅ Connected to ${service.name}: ws://${service.host}:${service.port}/ws', + ); final subscription = channel.stream.listen( (message) { diff --git a/lib/provider/proxy_playlist/next_fetcher_mixin.dart b/lib/provider/proxy_playlist/next_fetcher_mixin.dart deleted file mode 100644 index 1d2cfde8..00000000 --- a/lib/provider/proxy_playlist/next_fetcher_mixin.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; - -final logger = getLogger("NextFetcherMixin"); - -mixin NextFetcher on StateNotifier { - Future> fetchTracks( - Ref ref, { - int count = 3, - int offset = 0, - }) async { - /// get [count] [state.tracks] that are not [SourcedTrack] and [LocalTrack] - - final bareTracks = state.tracks - .skip(offset) - .where((element) => element is! SourcedTrack && element is! LocalTrack) - .take(count); - - /// fetch [bareTracks] one by one with 100ms delay - final fetchedTracks = await Future.wait( - bareTracks.mapIndexed((i, track) async { - final future = SourcedTrack.fetchFromTrack( - ref: ref, - track: track, - ); - if (i == 0) { - return await future; - } - return await Future.delayed( - const Duration(milliseconds: 100), - () => future, - ); - }), - ); - - return fetchedTracks; - } - - /// Merges List of [SourcedTrack]s with [Track]s and outputs a mixed List - Set mergeTracks( - Iterable fetchTracks, - Iterable tracks, - ) { - return tracks.map((track) { - final fetchedTrack = fetchTracks.firstWhereOrNull( - (fetchTrack) => fetchTrack.id == track.id, - ); - if (fetchedTrack != null) { - return fetchedTrack; - } - return track; - }).toSet(); - } - - /// Checks if [Track] is playable - bool isUnPlayable(String source) { - return source.startsWith('https://youtube.com/unplayable.m4a?id='); - } - - bool isPlayable(String source) => !isUnPlayable(source); - - /// Returns [Track.id] from [isUnPlayable] source that is not playable - String getIdFromUnPlayable(String source) { - return source - .split('&') - .first - .replaceFirst('https://youtube.com/unplayable.m4a?id=', ''); - } - - /// Returns appropriate Media source for [Track] - /// - /// * If [Track] is [SourcedTrack] then return [SourcedTrack.ytUri] - /// * If [Track] is [LocalTrack] then return [LocalTrack.path] - /// * If [Track] is [Track] then return [Track.id] with [isUnPlayable] source - String makeAppropriateSource(Track track) { - if (track is SourcedTrack) { - return track.url; - } else if (track is LocalTrack) { - return track.path; - } else { - return trackToUnplayableSource(track); - } - } - - String trackToUnplayableSource(Track track) { - return "https://youtube.com/unplayable.m4a?id=${track.id}&title=${Uri.encodeComponent(track.name!)}"; - } - - List mapSourcesToTracks(List sources) { - return sources - .map((source) { - final track = state.tracks.firstWhereOrNull( - (track) => - trackToUnplayableSource(track) == source || - (track is SourcedTrack && track.url == source) || - (track is LocalTrack && track.path == source), - ); - return track; - }) - .whereNotNull() - .toList(); - } -} diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index 9069f3e1..f86ad3d4 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -3,87 +3,25 @@ import 'dart:async'; import 'package:catcher_2/catcher_2.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/skip_segments.dart'; +import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/sourced_track/exceptions.dart'; extension ProxyPlaylistListeners on ProxyPlaylistNotifier { - StreamSubscription subscribeToSourceChanges() => - audioPlayer.activeSourceChangedStream.listen((event) { - try { - final newActiveTrack = mapSourcesToTracks([event]).firstOrNull; + StreamSubscription subscribeToPlaylist() { + return audioPlayer.playlistStream.listen((playlist) { + state = state.copyWith( + tracks: playlist.medias + .map((media) => SpotubeMedia.fromMedia(media).track) + .toSet(), + active: playlist.index, + ); - if (newActiveTrack == null || - newActiveTrack.id == state.activeTrack?.id) { - return; - } - - notificationService.addTrack(newActiveTrack); - discord.updatePresence(newActiveTrack); - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == newActiveTrack.id), - ); - - updatePalette(); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }); - - StreamSubscription subscribeToPercentCompletion() { - final isPreSearching = ObjectRef(false); - - return audioPlayer.percentCompletedStream(2).listen((event) async { - if (isPreSearching.value || - audioPlayer.currentSource == null || - audioPlayer.nextSource == null || - isPlayable(audioPlayer.nextSource!)) return; - - try { - isPreSearching.value = true; - - final track = await ensureSourcePlayable(audioPlayer.nextSource!); - - if (track != null) { - state = state.copyWith(tracks: mergeTracks([track], state.tracks)); - } - } catch (e, stackTrace) { - // Removing tracks that were not found to avoid queue interruption - if (e is TrackNotFoundError) { - final oldTrack = - mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - await removeTrack(oldTrack!.id!); - } - Catcher2.reportCheckedError(e, stackTrace); - } finally { - isPreSearching.value = false; - } - }); - } - - StreamSubscription subscribeToShuffleChanges() { - return audioPlayer.shuffledStream.listen((event) { - try { - final newlyOrderedTracks = mapSourcesToTracks(audioPlayer.sources); - - final newActiveIndex = newlyOrderedTracks.indexWhere( - (element) => element.id == state.activeTrack?.id, - ); - - if (newActiveIndex == -1) return; - - state = state.copyWith( - tracks: newlyOrderedTracks.toSet(), - active: newActiveIndex, - ); - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } + notificationService.addTrack(state.activeTrack!); + discord.updatePresence(state.activeTrack!); + updatePalette(); }); } @@ -126,6 +64,24 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { }); } + StreamSubscription subscribeToPosition() { + String lastTrack = ""; // used to prevent multiple calls to the same track + return audioPlayer.positionStream.listen((event) async { + if (event < const Duration(seconds: 3) || + state.active == null || + state.active == state.tracks.length - 1) return; + final nextTrack = state.tracks.elementAt(state.active! + 1); + + if (lastTrack == nextTrack.id || nextTrack is LocalTrack) return; + + try { + await ref.read(sourcedTrackProvider(nextTrack).future); + } finally { + lastTrack = nextTrack.id!; + } + }); + } + StreamSubscription subscribeToPlayerError() { return audioPlayer.errorStream.listen((event) {}); } diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index efc818ed..f70301ff 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -1,5 +1,4 @@ import 'package:collection/collection.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; @@ -14,12 +13,11 @@ class ProxyPlaylist { factory ProxyPlaylist.fromJson( Map json, - Ref ref, ) { return ProxyPlaylist( List.castFrom>( json['tracks'] ?? >[], - ).map((t) => _makeAppropriateTrack(t, ref)).toSet(), + ).map((t) => _makeAppropriateTrack(t)).toSet(), json['active'] as int?, json['collections'] == null ? {} @@ -40,10 +38,7 @@ class ProxyPlaylist { Track? get activeTrack => active == null || active == -1 ? null : tracks.elementAtOrNull(active!); - bool get isFetching => - activeTrack != null && - activeTrack is! SourcedTrack && - activeTrack is! LocalTrack; + bool get isFetching => activeTrack == null && tracks.isNotEmpty; bool containsCollection(String collection) { return collections.contains(collection); @@ -58,10 +53,8 @@ class ProxyPlaylist { return tracks.every(containsTrack); } - static Track _makeAppropriateTrack(Map track, Ref ref) { - if (track.containsKey("ytUri")) { - return SourcedTrack.fromJson(track, ref: ref); - } else if (track.containsKey("path")) { + static Track _makeAppropriateTrack(Map track) { + if (track.containsKey("path")) { return LocalTrack.fromJson(track); } else { return Track.fromJson(track); diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 438088de..bf039395 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -1,17 +1,15 @@ import 'dart:async'; import 'dart:math'; -import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/models/local_track.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/proxy_playlist/next_fetcher_mixin.dart'; import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; @@ -20,13 +18,10 @@ 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/provider/discord_provider.dart'; -import 'package:spotube/services/sourced_track/models/source_info.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -class ProxyPlaylistNotifier extends PersistedStateNotifier - with NextFetcher { +class ProxyPlaylistNotifier extends PersistedStateNotifier { final Ref ref; late final AudioServices notificationService; @@ -54,49 +49,23 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier _subscriptions = [ // These are subscription methods from player_listeners.dart - subscribeToSourceChanges(), - subscribeToPercentCompletion(), - subscribeToShuffleChanges(), + subscribeToPlaylist(), subscribeToSkipSponsor(), + subscribeToPosition(), subscribeToScrobbleChanged(), ]; } - - Future ensureSourcePlayable(String source) async { - if (isPlayable(source)) return null; - - final track = mapSourcesToTracks([source]).firstOrNull; - - if (track == null || track is LocalTrack) { - return null; - } - - final nthFetchedTrack = switch (track.runtimeType) { - SourcedTrack() => track as SourcedTrack, - _ => await SourcedTrack.fetchFromTrack(ref: ref, track: track), - }; - - await audioPlayer.replaceSource( - source, - nthFetchedTrack.url, - ); - - return nthFetchedTrack; - } - // Basic methods for adding or removing tracks to playlist Future addTrack(Track track) async { if (blacklist.contains(track)) return; - state = state.copyWith(tracks: {...state.tracks, track}); - await audioPlayer.addTrack(makeAppropriateSource(track)); + await audioPlayer.addTrack(SpotubeMedia(track)); } Future addTracks(Iterable tracks) async { tracks = blacklist.filter(tracks).toList() as List; - state = state.copyWith(tracks: {...state.tracks, ...tracks}); for (final track in tracks) { - await audioPlayer.addTrack(makeAppropriateSource(track)); + await audioPlayer.addTrack(SpotubeMedia(track)); } } @@ -114,25 +83,17 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } Future removeTrack(String trackId) async { - final track = - state.tracks.firstWhereOrNull((element) => element.id == trackId); - if (track == null) return; - state = state.copyWith(tracks: {...state.tracks..remove(track)}); - final index = audioPlayer.sources.indexOf(makeAppropriateSource(track)); - if (index == -1) return; - await audioPlayer.removeTrack(index); + final trackIndex = + state.tracks.toList().indexWhere((element) => element.id == trackId); + if (trackIndex == -1) return; + await audioPlayer.removeTrack(trackIndex); } Future removeTracks(Iterable tracksIds) async { - final tracks = - state.tracks.where((element) => tracksIds.contains(element.id)); - - state = state.copyWith(tracks: { - ...state.tracks..removeWhere((element) => tracksIds.contains(element.id)) - }); + final tracks = state.tracks.map((t) => t.id!).toList(); for (final track in tracks) { - final index = audioPlayer.sources.indexOf(makeAppropriateSource(track)); + final index = tracks.indexOf(track); if (index == -1) continue; await audioPlayer.removeTrack(index); } @@ -144,64 +105,18 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier bool autoPlay = false, }) async { tracks = blacklist.filter(tracks).toList() as List; - final indexTrack = tracks.elementAtOrNull(initialIndex) ?? tracks.first; - if (indexTrack is LocalTrack) { - state = state.copyWith( - tracks: tracks.toSet(), - active: initialIndex, - collections: {}, - ); - await notificationService.addTrack(indexTrack); - discord.updatePresence(indexTrack); - } else { - final addableTrack = await SourcedTrack.fetchFromTrack( - ref: ref, - track: tracks.elementAtOrNull(initialIndex) ?? tracks.first, - ).catchError((e, stackTrace) { - return SourcedTrack.fetchFromTrack( - ref: ref, - track: tracks.elementAtOrNull(initialIndex + 1) ?? tracks.first, - ); - }); - - state = state.copyWith( - tracks: mergeTracks([addableTrack], tracks), - active: initialIndex, - collections: {}, - ); - await notificationService.addTrack(addableTrack); - discord.updatePresence(addableTrack); - } + state = state.copyWith(collections: {}); await audioPlayer.openPlaylist( - state.tracks.map(makeAppropriateSource).toList(), + tracks.asMediaList(), initialIndex: initialIndex, autoPlay: autoPlay, ); } Future jumpTo(int index) async { - final oldTrack = - mapSourcesToTracks([audioPlayer.currentSource!]).firstOrNull; - - state = state.copyWith(active: index); - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.sources[index]); - - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: index, - ); - } - await audioPlayer.jumpTo(index); - - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } } Future jumpToTrack(Track track) async { @@ -211,7 +126,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier await jumpTo(index); } - // TODO: add safe guards for active/playing track that needs to be moved Future moveTrack(int oldIndex, int newIndex) async { if (oldIndex == newIndex || newIndex < 0 || @@ -219,11 +133,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier newIndex > state.tracks.length - 1 || oldIndex > state.tracks.length - 1) return; - final tracks = state.tracks.toList(); - final track = tracks.removeAt(oldIndex); - tracks.insert(newIndex, track); - state = state.copyWith(tracks: {...tracks}); - await audioPlayer.moveTrack(oldIndex, newIndex); } @@ -233,104 +142,23 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier } tracks = blacklist.filter(tracks).toList() as List; - final destIndex = state.active != null ? state.active! + 1 : 0; - final newTracks = state.tracks.toList()..insertAll(destIndex, tracks); - state = state.copyWith(tracks: newTracks.toSet()); - tracks.forEachIndexed((index, track) async { - audioPlayer.addTrackAt( - makeAppropriateSource(track), - destIndex + index, - ); - }); - } + for (int i = 0; i < tracks.length; i++) { + final track = tracks.elementAt(i); - Future populateSibling() async { - if (state.activeTrack is SourcedTrack) { - final activeTrackWithSiblingsForSure = - await (state.activeTrack as SourcedTrack).copyWithSibling(); - - state = state.copyWith( - tracks: mergeTracks([activeTrackWithSiblingsForSure], state.tracks), - active: state.tracks.toList().indexWhere( - (element) => element.id == activeTrackWithSiblingsForSure.id), - ); - } - } - - Future swapSibling(SourceInfo sibling) async { - if (state.activeTrack is SourcedTrack) { - await populateSibling(); - final newTrack = - await (state.activeTrack as SourcedTrack).swapWithSibling(sibling); - if (newTrack == null) return; - state = state.copyWith( - tracks: mergeTracks([newTrack], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == newTrack.id), - ); - await audioPlayer.pause(); - await audioPlayer.replaceSource( - audioPlayer.currentSource!, - makeAppropriateSource(newTrack), + await audioPlayer.addTrackAt( + SpotubeMedia(track), + (state.active ?? 0) + i + 1, ); } } Future next() async { - if (audioPlayer.nextSource == null) return; - final oldTrack = mapSourcesToTracks([audioPlayer.nextSource!]).firstOrNull; - - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == oldTrack?.id), - ); - - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.nextSource!); - - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == track.id), - ); - } await audioPlayer.skipToNext(); - - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } } Future previous() async { - if (audioPlayer.previousSource == null) return; - final oldTrack = - mapSourcesToTracks([audioPlayer.previousSource!]).firstOrNull; - state = state.copyWith( - active: state.tracks - .toList() - .indexWhere((element) => element.id == oldTrack?.id), - ); - await audioPlayer.pause(); - final track = await ensureSourcePlayable(audioPlayer.previousSource!); - if (track != null) { - state = state.copyWith( - tracks: mergeTracks([track], state.tracks), - active: state.tracks - .toList() - .indexWhere((element) => element.id == track.id), - ); - } await audioPlayer.skipToPrevious(); - if (oldTrack != null || track != null) { - await notificationService.addTrack(track ?? oldTrack!); - discord.updatePresence(track ?? oldTrack!); - } } Future stop() async { @@ -385,7 +213,7 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier @override FutureOr fromJson(Map json) { - return ProxyPlaylist.fromJson(json, ref); + return ProxyPlaylist.fromJson(json); } @override diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart index 94a63324..2d90eea6 100644 --- a/lib/provider/proxy_playlist/skip_segments.dart +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -3,12 +3,10 @@ import 'dart:convert'; import 'package:catcher_2/catcher_2.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; -import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/skip_segment.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; class SourcedSegments { final String source; @@ -75,13 +73,9 @@ Future> getAndCacheSkipSegments(String id) async { final segmentProvider = FutureProvider( (ref) async { - final track = ref.watch( - ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), - ); + final track = ref.watch(activeSourcedTrackProvider); if (track == null) return null; - if (track is LocalTrack || track is! SourcedTrack) return null; - final skipNonMusic = ref.watch( userPreferencesProvider.select( (s) { diff --git a/lib/provider/server/active_sourced_track.dart b/lib/provider/server/active_sourced_track.dart new file mode 100644 index 00000000..6ecd67b4 --- /dev/null +++ b/lib/provider/server/active_sourced_track.dart @@ -0,0 +1,47 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/sourced_track/models/source_info.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; + +class ActiveSourcedTrackNotifier extends Notifier { + @override + build() { + return null; + } + + void update(SourcedTrack? sourcedTrack) { + state = sourcedTrack; + } + + Future populateSibling() async { + if (state == null) return; + state = await state!.copyWithSibling(); + } + + Future swapSibling(SourceInfo sibling) async { + if (state == null) return; + await populateSibling(); + final newTrack = await state!.swapWithSibling(sibling); + if (newTrack == null) return; + + state = newTrack; + await audioPlayer.pause(); + + final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier); + final oldActiveIndex = audioPlayer.currentIndex; + + await playbackNotifier.addTracksAtFirst([newTrack]); + await Future.delayed(const Duration(milliseconds: 50)); + await playbackNotifier.jumpToTrack(newTrack); + + await audioPlayer.removeTrack(oldActiveIndex); + + await audioPlayer.resume(); + } +} + +final activeSourcedTrackProvider = + NotifierProvider( + () => ActiveSourcedTrackNotifier(), +); diff --git a/lib/provider/server/server.dart b/lib/provider/server/server.dart new file mode 100644 index 00000000..48f32a3c --- /dev/null +++ b/lib/provider/server/server.dart @@ -0,0 +1,119 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:dio/dio.dart' hide Response; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logger/logger.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:spotube/models/logger.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/server/active_sourced_track.dart'; +import 'package:spotube/provider/server/sourced_track.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + +class PlaybackServer { + final Ref ref; + UserPreferences get userPreferences => ref.read(userPreferencesProvider); + ProxyPlaylist get playlist => ref.read(ProxyPlaylistNotifier.provider); + final Logger logger; + final Dio dio; + + final Router router; + + static final port = Random().nextInt(17000) + 1500; + + PlaybackServer(this.ref) + : logger = getLogger('PlaybackServer'), + dio = Dio(), + router = Router() { + router.get('/stream/', getStreamTrackId); + + const pipeline = Pipeline(); + + if (kDebugMode) { + pipeline.addMiddleware(logRequests()); + } + + serve(pipeline.addHandler(router.call), InternetAddress.loopbackIPv4, port) + .then((server) { + logger + .t('Playback server at http://${server.address.host}:${server.port}'); + + ref.onDispose(() { + dio.close(force: true); + server.close(); + }); + }); + } + + /// @get('/stream/') + Future getStreamTrackId(Request request, String trackId) async { + try { + final track = + playlist.tracks.firstWhere((element) => element.id == trackId); + final activeSourcedTrack = ref.read(activeSourcedTrackProvider); + final sourcedTrack = activeSourcedTrack?.id == track.id + ? activeSourcedTrack + : await ref.read(sourcedTrackProvider(track).future); + + ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); + + final res = await dio.get( + sourcedTrack!.url, + options: Options( + headers: { + ...request.headers, + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "host": Uri.parse(sourcedTrack.url).host, + "Cache-Control": "max-age=0", + "Connection": "keep-alive", + }, + responseType: ResponseType.stream, + validateStatus: (status) => status! < 500, + ), + ); + + final audioStream = + (res.data?.stream as Stream?)?.asBroadcastStream(); + + // if (res.statusCode! > 300) { + // debugPrint( + // "[[Request]]\n" + // "URI: ${res.requestOptions.uri}\n" + // "Status: ${res.statusCode}\n" + // "Request Headers: ${res.requestOptions.headers}\n" + // "Response Body: ${res.data}\n" + // "Response Headers: ${res.headers.map}", + // ); + // } + + audioStream!.listen( + (event) {}, + cancelOnError: true, + ); + + return Response( + res.statusCode!, + body: audioStream, + context: { + "shelf.io.buffer_output": false, + }, + headers: res.headers.map, + ); + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + return Response.internalServerError(); + } + } +} + +final playbackServerProvider = Provider((ref) { + return PlaybackServer(ref); +}); diff --git a/lib/provider/server/sourced_track.dart b/lib/provider/server/sourced_track.dart new file mode 100644 index 00000000..ffa62213 --- /dev/null +++ b/lib/provider/server/sourced_track.dart @@ -0,0 +1,28 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; + +final sourcedTrackProvider = + FutureProvider.family((ref, track) async { + if (track == null || track is LocalTrack) { + return null; + } + + ref.listen( + ProxyPlaylistNotifier.provider, + (old, next) { + if (next.tracks.isEmpty || + next.tracks.none((element) => element.id == track.id)) { + ref.invalidateSelf(); + } + }, + ); + + final sourcedTrack = + await SourcedTrack.fetchFromTrack(track: track, ref: ref); + + return sourcedTrack; +}); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 0a22bec1..d5ebddb4 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,6 +1,12 @@ +import 'dart:io'; + import 'package:catcher_2/catcher_2.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:spotube/services/audio_player/mk_state_player.dart'; +import 'package:flutter/foundation.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/server/server.dart'; +import 'package:spotube/services/audio_player/custom_player.dart'; // import 'package:just_audio/just_audio.dart' as ja; import 'dart:async'; @@ -8,19 +14,42 @@ import 'package:media_kit/media_kit.dart' as mk; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; +class SpotubeMedia extends mk.Media { + final Track track; + + SpotubeMedia( + this.track, { + Map? extras, + super.httpHeaders, + }) : super( + track is LocalTrack + ? track.path + : "http://${InternetAddress.loopbackIPv4.address}:${PlaybackServer.port}/stream/${track.id}", + extras: { + ...?extras, + "track": track.toJson(), + }, + ); + + factory SpotubeMedia.fromMedia(mk.Media media) { + final track = Track.fromJson(media.extras?["track"]); + return SpotubeMedia(track); + } +} + abstract class AudioPlayerInterface { - final MkPlayerWithState _mkPlayer; + final CustomPlayer _mkPlayer; // final ja.AudioPlayer? _justAudxio; AudioPlayerInterface() - : _mkPlayer = MkPlayerWithState( + : _mkPlayer = CustomPlayer( configuration: const mk.PlayerConfiguration( title: "Spotube", + logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error, ), ) // _justAudio = !_mkSupportedPlatform ? ja.AudioPlayer() : null @@ -61,18 +90,18 @@ abstract class AudioPlayerInterface { } } - Future get selectedDevice async { + Future get selectedDevice async { return _mkPlayer.state.audioDevice; } - Future> get devices async { + Future> get devices async { return _mkPlayer.state.audioDevices; } bool get hasSource { - return _mkPlayer.playlist.medias.isNotEmpty; + return _mkPlayer.state.playlist.medias.isNotEmpty; // if (mkSupportedPlatform) { - // return _mkPlayer.playlist.medias.isNotEmpty; + // return _mkPlayer.state.playlist.medias.isNotEmpty; // } else { // return _justAudio!.audioSource != null; // } @@ -125,7 +154,7 @@ abstract class AudioPlayerInterface { } PlaybackLoopMode get loopMode { - return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); + return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.state.playlistMode); // if (mkSupportedPlatform) { // return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); // } else { diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index bfa13220..58868aed 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -4,320 +4,129 @@ final audioPlayer = SpotubeAudioPlayer(); class SpotubeAudioPlayer extends AudioPlayerInterface with SpotubeAudioPlayersStreams { - Object _resolveUrlType(String url) { - // if (mkSupportedPlatform) { - return mk.Media(url); - // } else { - // if (url.startsWith("https")) { - // return ja.AudioSource.uri(Uri.parse(url)); - // } else { - // return ja.AudioSource.file(url); - // } - // } - } - - Future preload(String url) async { - throw UnimplementedError(); - // final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is ap.Source) { - // // audioplayers doesn't have the capability to preload - // return; - // } else { - // return; - // } - } - - Future play(String url) async { - final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.open(urlType as mk.Media, play: true); - // } else { - // if (_justAudio?.audioSource is ja.ProgressiveAudioSource && - // (_justAudio?.audioSource as ja.ProgressiveAudioSource) - // .uri - // .toString() == - // url) { - // await _justAudio?.play(); - // } else { - // await _justAudio?.stop(); - // await _justAudio?.setAudioSource( - // urlType as ja.AudioSource, - // preload: true, - // ); - // await _justAudio?.play(); - // } - // } - } - Future pause() async { await _mkPlayer.pause(); - // await _justAudio?.pause(); } Future resume() async { await _mkPlayer.play(); - // await _justAudio?.play(); } Future stop() async { await _mkPlayer.stop(); - // await _justAudio?.stop(); - // await _justAudio?.setShuffleModeEnabled(false); - // await _justAudio?.setLoopMode(ja.LoopMode.off); } Future seek(Duration position) async { await _mkPlayer.seek(position); - // await _justAudio?.seek(position); } /// Volume is between 0 and 1 Future setVolume(double volume) async { assert(volume >= 0 && volume <= 1); await _mkPlayer.setVolume(volume * 100); - // await _justAudio?.setVolume(volume); } Future setSpeed(double speed) async { await _mkPlayer.setRate(speed); - // await _justAudio?.setSpeed(speed); } - Future setAudioDevice(AudioDevice device) async { + Future setAudioDevice(mk.AudioDevice device) async { await _mkPlayer.setAudioDevice(device); } Future dispose() async { await _mkPlayer.dispose(); - // await _justAudio?.dispose(); } // Playlist related Future openPlaylist( - List tracks, { + List tracks, { bool autoPlay = true, int initialIndex = 0, }) async { assert(tracks.isNotEmpty); assert(initialIndex <= tracks.length - 1); - // if (mkSupportedPlatform) { await _mkPlayer.open( - mk.Playlist( - tracks.map(mk.Media.new).toList(), - index: initialIndex, - ), + mk.Playlist(tracks, index: initialIndex), play: autoPlay, ); - // } else { - // await _justAudio!.setAudioSource( - // ja.ConcatenatingAudioSource( - // useLazyPreparation: true, - // children: - // tracks.map((e) => ja.AudioSource.uri(Uri.parse(e))).toList(), - // ), - // preload: true, - // initialIndex: initialIndex, - // ); - // if (autoPlay) { - // await _justAudio!.play(); - // } - // } - } - - // TODO: Make sure audio player soruces are also - // TODO: changed when preferences sources are changed - List resolveTracksForSource(List tracks) { - return tracks.where((e) => sources.contains(e.url)).toList(); - } - - bool tracksExistsInPlaylist(List tracks) { - return resolveTracksForSource(tracks).length == tracks.length; } List get sources { - // if (mkSupportedPlatform) { - return _mkPlayer.playlist.medias.map((e) => e.uri).toList(); - // } else { - // return _justAudio!.sequenceState?.effectiveSequence - // .map((e) => (e as ja.UriAudioSource).uri.toString()) - // .toList() ?? - // []; - // } + return _mkPlayer.state.playlist.medias.map((e) => e.uri).toList(); } String? get currentSource { - // if (mkSupportedPlatform) { - if (_mkPlayer.playlist.index == -1) return null; - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index) + if (_mkPlayer.state.playlist.index == -1) return null; + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index) ?.uri; - // } else { - // return (_justAudio?.sequenceState?.effectiveSequence - // .elementAtOrNull(_justAudio!.sequenceState!.currentIndex) - // as ja.UriAudioSource?) - // ?.uri - // .toString(); - // } } String? get nextSource { - // if (mkSupportedPlatform) { - if (loopMode == PlaybackLoopMode.all && - _mkPlayer.playlist.index == _mkPlayer.playlist.medias.length - 1) { + _mkPlayer.state.playlist.index == + _mkPlayer.state.playlist.medias.length - 1) { return sources.first; } - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index + 1) + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index + 1) ?.uri; - // } else { - // return (_justAudio?.sequenceState?.effectiveSequence - // .elementAtOrNull(_justAudio!.sequenceState!.currentIndex + 1) - // as ja.UriAudioSource?) - // ?.uri - // .toString(); - // } } String? get previousSource { - if (loopMode == PlaybackLoopMode.all && _mkPlayer.playlist.index == 0) { + if (loopMode == PlaybackLoopMode.all && + _mkPlayer.state.playlist.index == 0) { return sources.last; } - // if (mkSupportedPlatform) { - return _mkPlayer.playlist.medias - .elementAtOrNull(_mkPlayer.playlist.index - 1) + return _mkPlayer.state.playlist.medias + .elementAtOrNull(_mkPlayer.state.playlist.index - 1) ?.uri; - // } else { - // return (_justAudio?.sequenceState?.effectiveSequence - // .elementAtOrNull(_justAudio!.sequenceState!.currentIndex - 1) - // as ja.UriAudioSource?) - // ?.uri - // .toString(); - // } } + int get currentIndex => _mkPlayer.state.playlist.index; + Future skipToNext() async { - // if (mkSupportedPlatform) { await _mkPlayer.next(); - // } else { - // await _justAudio!.seekToNext(); - // } } Future skipToPrevious() async { - // if (mkSupportedPlatform) { await _mkPlayer.previous(); - // } else { - // await _justAudio!.seekToPrevious(); - // } } Future jumpTo(int index) async { - // if (mkSupportedPlatform) { await _mkPlayer.jump(index); - // } else { - // await _justAudio!.seek(Duration.zero, index: index); - // } } - Future addTrack(String url) async { - final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.add(urlType as mk.Media); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .add(urlType as ja.AudioSource); - // } + Future addTrack(mk.Media media) async { + await _mkPlayer.add(media); } - Future addTrackAt(String url, int index) async { - final urlType = _resolveUrlType(url); - // if (mkSupportedPlatform && urlType is mk.Media) { - await _mkPlayer.insert(index, urlType as mk.Media); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .insert(index, urlType as ja.AudioSource); - // } + Future addTrackAt(mk.Media media, int index) async { + await _mkPlayer.insert(index, media); } Future removeTrack(int index) async { - // if (mkSupportedPlatform) { await _mkPlayer.remove(index); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .removeAt(index); - // } } Future moveTrack(int from, int to) async { - // if (mkSupportedPlatform) { await _mkPlayer.move(from, to); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource) - // .move(from, to); - // } - } - - Future replaceSource( - String oldSource, - String newSource, { - bool exclusive = false, - }) async { - final oldSourceIndex = sources.indexOf(oldSource); - if (oldSourceIndex == -1) return; - - // if (mkSupportedPlatform) { - _mkPlayer.replace(oldSource, newSource); - // } else { - // final playlist = _justAudio!.audioSource as ja.ConcatenatingAudioSource; - - // print('oldSource: $oldSource'); - // print('newSource: $newSource'); - // final oldSourceIndexInPlaylist = - // _justAudio?.sequenceState?.effectiveSequence.indexWhere( - // (e) => (e as ja.UriAudioSource).uri.toString() == oldSource, - // ); - - // print('oldSourceIndexInPlaylist: $oldSourceIndexInPlaylist'); - - // // ignores non existing source - // if (oldSourceIndexInPlaylist == null || oldSourceIndexInPlaylist == -1) { - // return; - // } - - // await playlist.removeAt(oldSourceIndexInPlaylist); - // await playlist.insert( - // oldSourceIndexInPlaylist, - // ja.AudioSource.uri(Uri.parse(newSource)), - // ); - // } } Future clearPlaylist() async { - // if (mkSupportedPlatform) { _mkPlayer.stop(); - // } else { - // await (_justAudio!.audioSource as ja.ConcatenatingAudioSource).clear(); - // } } Future setShuffle(bool shuffle) async { - // if (mkSupportedPlatform) { await _mkPlayer.setShuffle(shuffle); - // } else { - // await _justAudio!.setShuffleModeEnabled(shuffle); - // } } Future setLoopMode(PlaybackLoopMode loop) async { - // if (mkSupportedPlatform) { await _mkPlayer.setPlaylistMode(loop.toPlaylistMode()); - // } else { - // await _justAudio!.setLoopMode(loop.toLoopMode()); - // } } Future setAudioNormalization(bool normalize) async { diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index 54e36c6b..f6fe0630 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -73,7 +73,7 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { Stream get loopModeStream { // if (mkSupportedPlatform) { - return _mkPlayer.loopModeStream.map(PlaybackLoopMode.fromPlaylistMode); + return _mkPlayer.stream.playlistMode.map(PlaybackLoopMode.fromPlaylistMode); // } else { // return _justAudio!.loopModeStream // .map(PlaybackLoopMode.fromLoopMode) @@ -127,7 +127,7 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // if (mkSupportedPlatform) { return _mkPlayer.indexChangeStream .map((event) { - return _mkPlayer.playlist.medias.elementAtOrNull(event)?.uri; + return _mkPlayer.state.playlist.medias.elementAtOrNull(event)?.uri; }) .where((event) => event != null) .cast(); @@ -141,11 +141,13 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // } } - Stream> get devicesStream => + Stream> get devicesStream => _mkPlayer.stream.audioDevices.asBroadcastStream(); - Stream get selectedDeviceStream => + Stream get selectedDeviceStream => _mkPlayer.stream.audioDevice.asBroadcastStream(); Stream get errorStream => _mkPlayer.stream.error; + + Stream get playlistStream => _mkPlayer.stream.playlist; } diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart new file mode 100644 index 00000000..d273519e --- /dev/null +++ b/lib/services/audio_player/custom_player.dart @@ -0,0 +1,143 @@ +import 'dart:async'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:catcher_2/catcher_2.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:flutter_broadcasts/flutter_broadcasts.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:audio_session/audio_session.dart'; +// ignore: implementation_imports +import 'package:spotube/services/audio_player/playback_state.dart'; + +/// MediaKit [Player] by default doesn't have a state stream. +/// This class adds a state stream to the [Player] class. +class CustomPlayer extends Player { + final StreamController _playerStateStream; + final StreamController _shuffleStream; + + late final List _subscriptions; + + bool _shuffled; + int _androidAudioSessionId = 0; + String _packageName = ""; + AndroidAudioManager? _androidAudioManager; + + CustomPlayer({super.configuration}) + : _playerStateStream = StreamController.broadcast(), + _shuffleStream = StreamController.broadcast(), + _shuffled = false { + nativePlayer.setProperty("network-timeout", "120"); + + _subscriptions = [ + stream.buffering.listen((event) { + _playerStateStream.add(AudioPlaybackState.buffering); + }), + stream.playing.listen((playing) { + if (playing) { + _playerStateStream.add(AudioPlaybackState.playing); + } else { + _playerStateStream.add(AudioPlaybackState.paused); + } + }), + stream.completed.listen((isCompleted) async { + if (!isCompleted) return; + _playerStateStream.add(AudioPlaybackState.completed); + }), + stream.playlist.listen((event) { + if (event.medias.isEmpty) { + _playerStateStream.add(AudioPlaybackState.stopped); + } + }), + stream.error.listen((event) { + Catcher2.reportCheckedError('[MediaKitError] \n$event', null); + }), + ]; + PackageInfo.fromPlatform().then((packageInfo) { + _packageName = packageInfo.packageName; + }); + if (DesktopTools.platform.isAndroid) { + _androidAudioManager = AndroidAudioManager(); + AudioSession.instance.then((s) async { + _androidAudioSessionId = + await _androidAudioManager!.generateAudioSessionId(); + notifyAudioSessionUpdate(true); + + await nativePlayer.setProperty( + "audiotrack-session-id", + _androidAudioSessionId.toString(), + ); + await nativePlayer.setProperty("ao", "audiotrack,opensles,"); + }); + } + } + + Future notifyAudioSessionUpdate(bool active) async { + if (DesktopTools.platform.isAndroid) { + sendBroadcast( + BroadcastMessage( + name: active + ? "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION" + : "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION", + data: { + "android.media.extra.AUDIO_SESSION": _androidAudioSessionId, + "android.media.extra.PACKAGE_NAME": _packageName + }, + ), + ); + } + } + + bool get shuffled => _shuffled; + + Stream get playerStateStream => _playerStateStream.stream; + Stream get shuffleStream => _shuffleStream.stream; + Stream get indexChangeStream { + int oldIndex = state.playlist.index; + return stream.playlist.map((event) => event.index).where((newIndex) { + if (newIndex != oldIndex) { + oldIndex = newIndex; + return true; + } + return false; + }); + } + + @override + Future setShuffle(bool shuffle) async { + _shuffled = shuffle; + await super.setShuffle(shuffle); + _shuffleStream.add(shuffle); + } + + @override + Future stop() async { + await super.stop(); + + _shuffled = false; + _playerStateStream.add(AudioPlaybackState.stopped); + _shuffleStream.add(false); + } + + @override + Future dispose() async { + for (var element in _subscriptions) { + element.cancel(); + } + await notifyAudioSessionUpdate(false); + return super.dispose(); + } + + NativePlayer get nativePlayer => platform as NativePlayer; + + Future insert(int index, Media media) async { + await add(media); + await move(state.playlist.medias.length, index); + } + + Future setAudioNormalization(bool normalize) async { + if (normalize) { + await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5'); + } else { + await nativePlayer.setProperty('af', ''); + } + } +} diff --git a/lib/services/audio_player/mk_state_player.dart b/lib/services/audio_player/mk_state_player.dart deleted file mode 100644 index 8b796d66..00000000 --- a/lib/services/audio_player/mk_state_player.dart +++ /dev/null @@ -1,382 +0,0 @@ -import 'dart:async'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; -import 'package:catcher_2/catcher_2.dart'; -import 'package:collection/collection.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:flutter_broadcasts/flutter_broadcasts.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:audio_session/audio_session.dart'; -// ignore: implementation_imports -import 'package:spotube/services/audio_player/playback_state.dart'; - -/// MediaKit [Player] by default doesn't have a state stream. -/// This class adds a state stream to the [Player] class. -class MkPlayerWithState extends Player { - final StreamController _playerStateStream; - final StreamController _playlistStream; - final StreamController _shuffleStream; - final StreamController _loopModeStream; - - late final List _subscriptions; - - bool _shuffled; - PlaylistMode _loopMode; - - Playlist? _playlist; - List? _tempMedias; - int _androidAudioSessionId = 0; - String _packageName = ""; - AndroidAudioManager? _androidAudioManager; - - MkPlayerWithState({super.configuration}) - : _playerStateStream = StreamController.broadcast(), - _shuffleStream = StreamController.broadcast(), - _loopModeStream = StreamController.broadcast(), - _playlistStream = StreamController.broadcast(), - _shuffled = false, - _loopMode = PlaylistMode.none { - _subscriptions = [ - stream.buffering.listen((event) { - _playerStateStream.add(AudioPlaybackState.buffering); - }), - stream.playing.listen((playing) { - if (playing) { - _playerStateStream.add(AudioPlaybackState.playing); - } else { - _playerStateStream.add(AudioPlaybackState.paused); - } - }), - stream.completed.listen((isCompleted) async { - try { - if (!isCompleted) return; - - _playerStateStream.add(AudioPlaybackState.completed); - if (loopMode == PlaylistMode.single) { - await super.open(_playlist!.medias[_playlist!.index], play: true); - } else { - await next(); - await Future.delayed(const Duration(milliseconds: 250), play); - } - } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); - } - }), - stream.playlist.listen((event) { - if (event.medias.isEmpty) { - _playerStateStream.add(AudioPlaybackState.stopped); - } - }), - stream.error.listen((event) { - Catcher2.reportCheckedError('[MediaKitError] \n$event', null); - }), - ]; - PackageInfo.fromPlatform().then((packageInfo) { - _packageName = packageInfo.packageName; - }); - if (DesktopTools.platform.isAndroid) { - _androidAudioManager = AndroidAudioManager(); - AudioSession.instance.then((s) async { - _androidAudioSessionId = - await _androidAudioManager!.generateAudioSessionId(); - notifyAudioSessionUpdate(true); - - await nativePlayer.setProperty( - "audiotrack-session-id", - _androidAudioSessionId.toString(), - ); - await nativePlayer.setProperty("ao", "audiotrack,opensles,"); - }); - } - } - - Future notifyAudioSessionUpdate(bool active) async { - if (DesktopTools.platform.isAndroid) { - sendBroadcast( - BroadcastMessage( - name: active - ? "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION" - : "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION", - data: { - "android.media.extra.AUDIO_SESSION": _androidAudioSessionId, - "android.media.extra.PACKAGE_NAME": _packageName - }, - ), - ); - } - } - - bool get shuffled => _shuffled; - PlaylistMode get loopMode => _loopMode; - Playlist get playlist => _playlist ?? const Playlist([], index: -1); - - Stream get playerStateStream => _playerStateStream.stream; - Stream get shuffleStream => _shuffleStream.stream; - Stream get loopModeStream => _loopModeStream.stream; - Stream get playlistStream => _playlistStream.stream; - Stream get indexChangeStream { - int oldIndex = playlist.index; - return playlistStream.map((event) => event.index).where((newIndex) { - if (newIndex != oldIndex) { - oldIndex = newIndex; - return true; - } - return false; - }); - } - - set playlist(Playlist playlist) { - _playlist = playlist; - _playlistStream.add(playlist); - } - - @override - Future setShuffle(bool shuffle) async { - _shuffled = shuffle; - if (shuffle) { - _tempMedias = _playlist!.medias; - final active = _playlist!.medias[_playlist!.index]; - final newMedias = _playlist!.medias.toList() - ..shuffle() - ..remove(active) - ..insert(0, active); - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(active), - ); - } else { - if (_tempMedias == null) return; - playlist = _playlist!.copyWith( - medias: _tempMedias!, - index: _tempMedias?.indexOf( - _playlist!.medias[_playlist!.index], - ), - ); - _tempMedias = null; - } - await super.setShuffle(shuffle); - _shuffleStream.add(shuffle); - } - - @override - Future setPlaylistMode(PlaylistMode playlistMode) async { - _loopMode = playlistMode; - await super.setPlaylistMode(playlistMode); - _loopModeStream.add(playlistMode); - } - - @override - Future stop() async { - await super.stop(); - await pause(); - await seek(Duration.zero); - - _loopMode = PlaylistMode.none; - _shuffled = false; - _playlist = null; - _tempMedias = null; - _playerStateStream.add(AudioPlaybackState.stopped); - _shuffleStream.add(false); - } - - @override - Future dispose() async { - for (var element in _subscriptions) { - element.cancel(); - } - await notifyAudioSessionUpdate(false); - return super.dispose(); - } - - @override - Future open( - Playable playable, { - bool play = true, - }) async { - await stop(); - if (playable is Playlist) { - playlist = playable; - super.open(playable.medias[playable.index], play: play); - } - await super.open(playable, play: play); - } - - @override - Future next() async { - if (_playlist == null) { - return; - } - - final isLast = _playlist!.index == _playlist!.medias.length - 1; - - if (isLast) { - switch (loopMode) { - case PlaylistMode.loop: - playlist = _playlist!.copyWith(index: 0); - super.open(_playlist!.medias[_playlist!.index], play: true); - break; - case PlaylistMode.none: - // Fixes auto-repeating the last track - await super.stop(); - break; - default: - } - } else { - playlist = _playlist!.copyWith(index: _playlist!.index + 1); - - return super.open(_playlist!.medias[_playlist!.index], play: true); - } - } - - @override - Future previous() async { - if (_playlist == null || _playlist!.index - 1 < 0) return; - - if (loopMode == PlaylistMode.loop && _playlist!.index == 0) { - playlist = _playlist!.copyWith(index: _playlist!.medias.length - 1); - return super.open(_playlist!.medias[_playlist!.index], play: true); - } else if (_playlist!.index != 0) { - playlist = _playlist!.copyWith(index: _playlist!.index - 1); - return super.open(_playlist!.medias[_playlist!.index], play: true); - } - } - - @override - Future jump(int index) async { - if (_playlist == null || index < 0 || index >= _playlist!.medias.length) { - return; - } - - playlist = _playlist!.copyWith(index: index); - return super.open(_playlist!.medias[index], play: true); - } - - @override - Future move(int from, int to) async { - if (_playlist == null || - from >= _playlist!.medias.length || - to >= _playlist!.medias.length) return; - - final active = _playlist!.medias[_playlist!.index]; - final newPlaylist = _playlist!.copyWith( - medias: _playlist!.medias.mapIndexed((index, element) { - if (index == from) { - return _playlist!.medias[to]; - } else if (index == to) { - return _playlist!.medias[from]; - } - return element; - }).toList(), - ); - playlist = _playlist!.copyWith( - index: newPlaylist.medias.indexOf(active), - medias: newPlaylist.medias, - ); - } - - /// This replaces the old source with a new one - /// - /// If the old source is playing, the new one will play - /// from the beginning - /// - /// This doesn't work when [playlist] is null - void replace(String oldUrl, String newUrl) { - if (_playlist == null) { - return; - } - - final isOldUrlPlaying = _playlist!.medias[_playlist!.index].uri == oldUrl; - - // ends the loop where match is found - // tends to be a bit more efficient than forEach - _playlist!.medias.firstWhereIndexedOrNull((i, media) { - if (media.uri != oldUrl) return false; - if (isOldUrlPlaying) { - pause(); - } - final copyMedias = [..._playlist!.medias]; - copyMedias[i] = Media(newUrl, extras: media.extras); - playlist = _playlist!.copyWith(medias: copyMedias); - if (isOldUrlPlaying) { - super.open( - copyMedias[i], - play: true, - ); - } - - // replace in the _tempMedias if it's not null - if (shuffled && _tempMedias != null) { - final tempIndex = _tempMedias!.indexOf(media); - _tempMedias![tempIndex] = Media(newUrl, extras: media.extras); - } - return true; - }); - } - - @override - Future add(Media media) async { - if (_playlist == null) return; - - playlist = _playlist!.copyWith( - medias: [..._playlist!.medias, media], - ); - - if (shuffled && _tempMedias != null) { - _tempMedias!.add(media); - } - } - - FutureOr insert(int index, Media media) { - if (_playlist == null || - index < 0 || - (_playlist!.medias.length > 1 && - index > _playlist!.medias.length - 1)) { - return null; - } - - final newMedias = _playlist!.medias.toList()..insert(index, media); - - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(_playlist!.medias[_playlist!.index]), - ); - - if (shuffled && _tempMedias != null) { - _tempMedias!.insert(index, media); - } - } - - /// Doesn't work when active media is the one to be removed - @override - Future remove(int index) async { - if (_playlist == null || - index < 0 || - index > _playlist!.medias.length - 1 || - _playlist!.index == index) { - return; - } - - final targetItem = _playlist!.medias.elementAtOrNull(index); - if (targetItem == null) return; - - if (shuffled && _tempMedias != null) { - _tempMedias!.remove(targetItem); - } - - final newMedias = _playlist!.medias.toList()..removeAt(index); - - playlist = _playlist!.copyWith( - medias: newMedias, - index: newMedias.indexOf(_playlist!.medias[_playlist!.index]), - ); - } - - NativePlayer get nativePlayer => platform as NativePlayer; - - Future setAudioNormalization(bool normalize) async { - if (normalize) { - await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5'); - } else { - await nativePlayer.setProperty('af', ''); - } - } -} diff --git a/lib/services/audio_services/linux_audio_service.dart b/lib/services/audio_services/linux_audio_service.dart deleted file mode 100644 index 84a6f7b8..00000000 --- a/lib/services/audio_services/linux_audio_service.dart +++ /dev/null @@ -1,736 +0,0 @@ -import 'dart:io'; - -import 'package:dbus/dbus.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotube/extensions/image.dart'; - -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; - -final dbus = DBusClient.session(); - -class _MprisMediaPlayer2 extends DBusObject { - /// Creates a new object to expose on [path]. - _MprisMediaPlayer2() : super(DBusObjectPath('/org/mpris/MediaPlayer2')) { - dbus.registerObject(this); - } - - void dispose() { - dbus.unregisterObject(this); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanQuit - Future getCanQuit() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Fullscreen - Future getFullscreen() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Sets property org.mpris.MediaPlayer2.Fullscreen - Future setFullscreen(bool value) async { - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanSetFullscreen - Future getCanSetFullscreen() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.CanRaise - Future getCanRaise() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.HasTrackList - Future getHasTrackList() async { - return DBusMethodSuccessResponse([const DBusBoolean(false)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Identity - Future getIdentity() async { - return DBusMethodSuccessResponse([const DBusString("Spotube")]); - } - - /// Gets value of property org.mpris.MediaPlayer2.DesktopEntry - Future getDesktopEntry() async { - return DBusMethodSuccessResponse( - [const DBusString("/usr/share/application/spotube")], - ); - } - - /// Gets value of property org.mpris.MediaPlayer2.SupportedUriSchemes - Future getSupportedUriSchemes() async { - return DBusMethodSuccessResponse([ - DBusArray.string(["http"]) - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.SupportedMimeTypes - Future getSupportedMimeTypes() async { - return DBusMethodSuccessResponse([ - DBusArray.string(["audio/mpeg"]) - ]); - } - - /// Implementation of org.mpris.MediaPlayer2.Raise() - Future doRaise() async { - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Quit() - Future doQuit() async { - exit(0); - } - - @override - List introspect() { - return [ - DBusIntrospectInterface('org.mpris.MediaPlayer2', methods: [ - DBusIntrospectMethod('Raise'), - DBusIntrospectMethod('Quit') - ], properties: [ - DBusIntrospectProperty('CanQuit', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Fullscreen', DBusSignature('b'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('CanSetFullscreen', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanRaise', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('HasTrackList', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Identity', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('DesktopEntry', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('SupportedUriSchemes', DBusSignature('as'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('SupportedMimeTypes', DBusSignature('as'), - access: DBusPropertyAccess.read) - ]) - ]; - } - - @override - Future handleMethodCall(DBusMethodCall methodCall) async { - if (methodCall.interface == 'org.mpris.MediaPlayer2') { - if (methodCall.name == 'Raise') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doRaise(); - } else if (methodCall.name == 'Quit') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doQuit(); - } else { - return DBusMethodErrorResponse.unknownMethod(); - } - } else { - return DBusMethodErrorResponse.unknownInterface(); - } - } - - @override - Future getProperty(String interface, String name) async { - if (interface == 'org.mpris.MediaPlayer2') { - if (name == 'CanQuit') { - return getCanQuit(); - } else if (name == 'Fullscreen') { - return getFullscreen(); - } else if (name == 'CanSetFullscreen') { - return getCanSetFullscreen(); - } else if (name == 'CanRaise') { - return getCanRaise(); - } else if (name == 'HasTrackList') { - return getHasTrackList(); - } else if (name == 'Identity') { - return getIdentity(); - } else if (name == 'DesktopEntry') { - return getDesktopEntry(); - } else if (name == 'SupportedUriSchemes') { - return getSupportedUriSchemes(); - } else if (name == 'SupportedMimeTypes') { - return getSupportedMimeTypes(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future setProperty( - String interface, String name, DBusValue value) async { - if (interface == 'org.mpris.MediaPlayer2') { - if (name == 'CanQuit') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Fullscreen') { - if (value.signature != DBusSignature('b')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setFullscreen((value as DBusBoolean).value); - } else if (name == 'CanSetFullscreen') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanRaise') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'HasTrackList') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Identity') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'DesktopEntry') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'SupportedUriSchemes') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'SupportedMimeTypes') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future getAllProperties(String interface) async { - var properties = {}; - if (interface == 'org.mpris.MediaPlayer2') { - properties['CanQuit'] = (await getCanQuit()).returnValues[0]; - properties['Fullscreen'] = (await getFullscreen()).returnValues[0]; - properties['CanSetFullscreen'] = - (await getCanSetFullscreen()).returnValues[0]; - properties['CanRaise'] = (await getCanRaise()).returnValues[0]; - properties['HasTrackList'] = (await getHasTrackList()).returnValues[0]; - properties['Identity'] = (await getIdentity()).returnValues[0]; - properties['DesktopEntry'] = (await getDesktopEntry()).returnValues[0]; - properties['SupportedUriSchemes'] = - (await getSupportedUriSchemes()).returnValues[0]; - properties['SupportedMimeTypes'] = - (await getSupportedMimeTypes()).returnValues[0]; - } - return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); - } -} - -class _MprisMediaPlayer2Player extends DBusObject { - final Ref ref; - final ProxyPlaylistNotifier playlistNotifier; - - /// Creates a new object to expose on [path]. - _MprisMediaPlayer2Player(this.ref, this.playlistNotifier) - : super(DBusObjectPath("/org/mpris/MediaPlayer2")) { - (() async { - final nameStatus = - await dbus.requestName("org.mpris.MediaPlayer2.spotube"); - if (nameStatus == DBusRequestNameReply.exists) { - await dbus.requestName("org.mpris.MediaPlayer2.spotube.instance$pid"); - } - await dbus.registerObject(this); - }()); - } - - ProxyPlaylist get playlist => playlistNotifier.playlist; - - void dispose() { - dbus.unregisterObject(this); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.PlaybackStatus - Future getPlaybackStatus() async { - final status = audioPlayer.isPlaying - ? "Playing" - : playlist.active == null - ? "Stopped" - : "Paused"; - return DBusMethodSuccessResponse([DBusString(status)]); - } - - // TODO: Implement Track Loop - - /// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus - Future getLoopStatus() async { - final loopMode = switch (audioPlayer.loopMode) { - PlaybackLoopMode.all => "Playlist", - PlaybackLoopMode.one => "Track", - PlaybackLoopMode.none => "None", - }; - - return DBusMethodSuccessResponse([DBusString(loopMode)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.LoopStatus - Future setLoopStatus(String value) async { - // playlistNotifier.setIsLoop(value == "Track"); - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Rate - Future getRate() async { - return DBusMethodSuccessResponse([const DBusDouble(1)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.Rate - Future setRate(double value) async { - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle - Future getShuffle() async { - return DBusMethodSuccessResponse( - [DBusBoolean(await audioPlayer.isShuffled)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.Shuffle - Future setShuffle(bool value) async { - audioPlayer.setShuffle(value); - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Metadata - Future getMetadata() async { - if (playlist.activeTrack == null || playlist.isFetching) { - return DBusMethodSuccessResponse([DBusDict.stringVariant({})]); - } - final id = playlist.activeTrack!.id; - - return DBusMethodSuccessResponse([ - DBusDict.stringVariant({ - "mpris:trackid": DBusString("${path.value}/Track/$id"), - "mpris:length": DBusInt32( - (await audioPlayer.duration)?.inMicroseconds ?? 0, - ), - "mpris:artUrl": DBusString( - (playlist.activeTrack?.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - ), - "xesam:album": DBusString(playlist.activeTrack!.album!.name!), - "xesam:artist": DBusArray.string( - playlist.activeTrack!.artists!.map((artist) => artist.name!), - ), - "xesam:title": DBusString(playlist.activeTrack!.name!), - "xesam:url": DBusString( - playlist.activeTrack is SourcedTrack - ? (playlist.activeTrack as SourcedTrack).url - : playlist.activeTrack!.previewUrl ?? "", - ), - "xesam:genre": const DBusString("Unknown"), - }), - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Volume - Future getVolume() async { - return DBusMethodSuccessResponse([DBusDouble(audioPlayer.volume)]); - } - - /// Sets property org.mpris.MediaPlayer2.Player.Volume - Future setVolume(double value) async { - await audioPlayer.setVolume(value); - return DBusMethodSuccessResponse(); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.Position - Future getPosition() async { - return DBusMethodSuccessResponse([ - DBusInt64((await audioPlayer.position)?.inMicroseconds ?? 0), - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.MinimumRate - Future getMinimumRate() async { - return DBusMethodSuccessResponse([const DBusDouble(1)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.MaximumRate - Future getMaximumRate() async { - return DBusMethodSuccessResponse([const DBusDouble(1)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanGoNext - Future getCanGoNext() async { - return DBusMethodSuccessResponse([ - DBusBoolean( - (playlist.tracks.length) > 1, - ) - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanGoPrevious - Future getCanGoPrevious() async { - return DBusMethodSuccessResponse([ - DBusBoolean( - (playlist.tracks.length) > 1, - ) - ]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanPlay - Future getCanPlay() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanPause - Future getCanPause() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanSeek - Future getCanSeek() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Gets value of property org.mpris.MediaPlayer2.Player.CanControl - Future getCanControl() async { - return DBusMethodSuccessResponse([const DBusBoolean(true)]); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Next() - Future doNext() async { - await playlistNotifier.next(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Previous() - Future doPrevious() async { - await playlistNotifier.previous(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Pause() - Future doPause() async { - await audioPlayer.pause(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.PlayPause() - Future doPlayPause() async { - audioPlayer.isPlaying - ? await audioPlayer.pause() - : await audioPlayer.resume(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Stop() - Future doStop() async { - playlistNotifier.stop(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Play() - Future doPlay() async { - await audioPlayer.resume(); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.Seek() - Future doSeek(int offset) async { - await audioPlayer.seek(Duration(microseconds: offset)); - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.SetPosition() - Future doSetPosition(String TrackId, int Position) async { - return DBusMethodSuccessResponse(); - } - - /// Implementation of org.mpris.MediaPlayer2.Player.OpenUri() - Future doOpenUri(String Uri) async { - return DBusMethodSuccessResponse(); - } - - /// Emits signal org.mpris.MediaPlayer2.Player.Seeked - Future emitSeeked(int position) async { - await emitSignal( - 'org.mpris.MediaPlayer2.Player', - 'Seeked', - [DBusInt64(position)], - ); - } - - Future updateProperties() async { - return emitPropertiesChanged( - "org.mpris.MediaPlayer2.Player", - changedProperties: { - "PlaybackStatus": (await getPlaybackStatus()).returnValues.first, - "LoopStatus": (await getLoopStatus()).returnValues.first, - "Rate": (await getRate()).returnValues.first, - "Shuffle": (await getShuffle()).returnValues.first, - "Metadata": (await getMetadata()).returnValues.first, - "Volume": (await getVolume()).returnValues.first, - "Position": (await getPosition()).returnValues.first, - "MinimumRate": (await getMinimumRate()).returnValues.first, - "MaximumRate": (await getMaximumRate()).returnValues.first, - "CanGoNext": (await getCanGoNext()).returnValues.first, - "CanGoPrevious": (await getCanGoPrevious()).returnValues.first, - "CanPlay": (await getCanPlay()).returnValues.first, - "CanPause": (await getCanPause()).returnValues.first, - "CanSeek": (await getCanSeek()).returnValues.first, - "CanControl": (await getCanControl()).returnValues.first, - }, - ); - } - - @override - List introspect() { - return [ - DBusIntrospectInterface('org.mpris.MediaPlayer2.Player', methods: [ - DBusIntrospectMethod('Next'), - DBusIntrospectMethod('Previous'), - DBusIntrospectMethod('Pause'), - DBusIntrospectMethod('PlayPause'), - DBusIntrospectMethod('Stop'), - DBusIntrospectMethod('Play'), - DBusIntrospectMethod('Seek', args: [ - DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.in_, - name: 'Offset') - ]), - DBusIntrospectMethod('SetPosition', args: [ - DBusIntrospectArgument(DBusSignature('o'), DBusArgumentDirection.in_, - name: 'TrackId'), - DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.in_, - name: 'Position') - ]), - DBusIntrospectMethod('OpenUri', args: [ - DBusIntrospectArgument(DBusSignature('s'), DBusArgumentDirection.in_, - name: 'Uri') - ]) - ], signals: [ - DBusIntrospectSignal('Seeked', args: [ - DBusIntrospectArgument(DBusSignature('x'), DBusArgumentDirection.out, - name: 'Position') - ]) - ], properties: [ - DBusIntrospectProperty('PlaybackStatus', DBusSignature('s'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('LoopStatus', DBusSignature('s'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Rate', DBusSignature('d'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Shuffle', DBusSignature('b'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Metadata', DBusSignature('a{sv}'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('Volume', DBusSignature('d'), - access: DBusPropertyAccess.readwrite), - DBusIntrospectProperty('Position', DBusSignature('x'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('MinimumRate', DBusSignature('d'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('MaximumRate', DBusSignature('d'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanGoNext', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanGoPrevious', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanPlay', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanPause', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanSeek', DBusSignature('b'), - access: DBusPropertyAccess.read), - DBusIntrospectProperty('CanControl', DBusSignature('b'), - access: DBusPropertyAccess.read) - ]) - ]; - } - - @override - Future handleMethodCall(DBusMethodCall methodCall) async { - if (methodCall.interface == 'org.mpris.MediaPlayer2.Player') { - if (methodCall.name == 'Next') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doNext(); - } else if (methodCall.name == 'Previous') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPrevious(); - } else if (methodCall.name == 'Pause') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPause(); - } else if (methodCall.name == 'PlayPause') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPlayPause(); - } else if (methodCall.name == 'Stop') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doStop(); - } else if (methodCall.name == 'Play') { - if (methodCall.values.isNotEmpty) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doPlay(); - } else if (methodCall.name == 'Seek') { - if (methodCall.signature != DBusSignature('x')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doSeek((methodCall.values[0] as DBusInt64).value); - } else if (methodCall.name == 'SetPosition') { - if (methodCall.signature != DBusSignature('ox')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doSetPosition((methodCall.values[0] as DBusObjectPath).value, - (methodCall.values[1] as DBusInt64).value); - } else if (methodCall.name == 'OpenUri') { - if (methodCall.signature != DBusSignature('s')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return doOpenUri((methodCall.values[0] as DBusString).value); - } else { - return DBusMethodErrorResponse.unknownMethod(); - } - } else { - return DBusMethodErrorResponse.unknownInterface(); - } - } - - @override - Future getProperty(String interface, String name) async { - if (interface == 'org.mpris.MediaPlayer2.Player') { - if (name == 'PlaybackStatus') { - return getPlaybackStatus(); - } else if (name == 'LoopStatus') { - return getLoopStatus(); - } else if (name == 'Rate') { - return getRate(); - } else if (name == 'Shuffle') { - return getShuffle(); - } else if (name == 'Metadata') { - return getMetadata(); - } else if (name == 'Volume') { - return getVolume(); - } else if (name == 'Position') { - return getPosition(); - } else if (name == 'MinimumRate') { - return getMinimumRate(); - } else if (name == 'MaximumRate') { - return getMaximumRate(); - } else if (name == 'CanGoNext') { - return getCanGoNext(); - } else if (name == 'CanGoPrevious') { - return getCanGoPrevious(); - } else if (name == 'CanPlay') { - return getCanPlay(); - } else if (name == 'CanPause') { - return getCanPause(); - } else if (name == 'CanSeek') { - return getCanSeek(); - } else if (name == 'CanControl') { - return getCanControl(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future setProperty( - String interface, String name, DBusValue value) async { - if (interface == 'org.mpris.MediaPlayer2.Player') { - if (name == 'PlaybackStatus') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'LoopStatus') { - if (value.signature != DBusSignature('s')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setLoopStatus((value as DBusString).value); - } else if (name == 'Rate') { - if (value.signature != DBusSignature('d')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setRate((value as DBusDouble).value); - } else if (name == 'Shuffle') { - if (value.signature != DBusSignature('b')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setShuffle((value as DBusBoolean).value); - } else if (name == 'Metadata') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'Volume') { - if (value.signature != DBusSignature('d')) { - return DBusMethodErrorResponse.invalidArgs(); - } - return setVolume((value as DBusDouble).value); - } else if (name == 'Position') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'MinimumRate') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'MaximumRate') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanGoNext') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanGoPrevious') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanPlay') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanPause') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanSeek') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else if (name == 'CanControl') { - return DBusMethodErrorResponse.propertyReadOnly(); - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } else { - return DBusMethodErrorResponse.unknownProperty(); - } - } - - @override - Future getAllProperties(String interface) async { - var properties = {}; - if (interface == 'org.mpris.MediaPlayer2.Player') { - properties['PlaybackStatus'] = - (await getPlaybackStatus()).returnValues[0]; - properties['LoopStatus'] = (await getLoopStatus()).returnValues[0]; - properties['Rate'] = (await getRate()).returnValues[0]; - properties['Shuffle'] = (await getShuffle()).returnValues[0]; - properties['Metadata'] = (await getMetadata()).returnValues[0]; - properties['Volume'] = (await getVolume()).returnValues[0]; - properties['Position'] = (await getPosition()).returnValues[0]; - properties['MinimumRate'] = (await getMinimumRate()).returnValues[0]; - properties['MaximumRate'] = (await getMaximumRate()).returnValues[0]; - properties['CanGoNext'] = (await getCanGoNext()).returnValues[0]; - properties['CanGoPrevious'] = (await getCanGoPrevious()).returnValues[0]; - properties['CanPlay'] = (await getCanPlay()).returnValues[0]; - properties['CanPause'] = (await getCanPause()).returnValues[0]; - properties['CanSeek'] = (await getCanSeek()).returnValues[0]; - properties['CanControl'] = (await getCanControl()).returnValues[0]; - } - return DBusMethodSuccessResponse([DBusDict.stringVariant(properties)]); - } -} - -class LinuxAudioService { - _MprisMediaPlayer2 mp2; - _MprisMediaPlayer2Player player; - - LinuxAudioService(Ref ref, ProxyPlaylistNotifier playlistNotifier) - : mp2 = _MprisMediaPlayer2(), - player = _MprisMediaPlayer2Player(ref, playlistNotifier); - - void dispose() { - mp2.dispose(); - player.dispose(); - } -} diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index d259317e..3bb88447 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -11,6 +11,7 @@ class MobileAudioService extends BaseAudioHandler { AudioSession? session; final ProxyPlaylistNotifier playlistNotifier; + // ignore: invalid_use_of_protected_member ProxyPlaylist get playlist => playlistNotifier.state; MobileAudioService(this.playlistNotifier) { diff --git a/lib/services/audio_services/smtc_windows_web.dart b/lib/services/audio_services/smtc_windows_web.dart index 177f3ac5..055d43be 100644 --- a/lib/services/audio_services/smtc_windows_web.dart +++ b/lib/services/audio_services/smtc_windows_web.dart @@ -1,3 +1,5 @@ +// ignore_for_file: constant_identifier_names + class MusicMetadata { final String? title; final String? artist; diff --git a/lib/services/cli/cli.dart b/lib/services/cli/cli.dart index 61af710e..720216c7 100644 --- a/lib/services/cli/cli.dart +++ b/lib/services/cli/cli.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_print + import 'dart:io'; import 'package:args/args.dart'; diff --git a/lib/services/download_manager/download_task.dart b/lib/services/download_manager/download_task.dart index d65f167e..d79cf95b 100644 --- a/lib/services/download_manager/download_task.dart +++ b/lib/services/download_manager/download_task.dart @@ -28,8 +28,6 @@ class DownloadTask { } } - ; - status.addListener(listener); return completer.future.timeout(timeout); diff --git a/lib/utils/duration.dart b/lib/utils/duration.dart index 35678a96..a2bb4d16 100644 --- a/lib/utils/duration.dart +++ b/lib/utils/duration.dart @@ -37,8 +37,6 @@ Duration parseDuration(String input) { days = p ~/ 24; } - // TODO verify that there are no negative parts - return Duration( days: days, hours: hours, diff --git a/pubspec.lock b/pubspec.lock index b4e38b7f..411dc056 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1817,7 +1817,7 @@ packages: source: hosted version: "6.0.5" pub_api_client: - dependency: "direct dev" + dependency: "direct main" description: name: pub_api_client sha256: d456816ef5142906a22dc56e37be6bef6cb0276f0a26c11d1f7d277868202e71 @@ -1841,7 +1841,7 @@ packages: source: hosted version: "2.3.0" pubspec_parse: - dependency: "direct dev" + dependency: "direct main" description: name: pubspec_parse sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 diff --git a/pubspec.yaml b/pubspec.yaml index a9b4ed62..dfd77387 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -129,6 +129,8 @@ dependencies: shelf_web_socket: ^1.0.4 web_socket_channel: ^2.4.4 lrc: ^1.0.2 + pub_api_client: ^2.4.0 + pubspec_parse: ^1.2.2 dev_dependencies: build_runner: ^2.4.9 @@ -143,8 +145,6 @@ dev_dependencies: sdk: flutter hive_generator: ^2.0.0 json_serializable: ^6.6.2 - pub_api_client: ^2.4.0 - pubspec_parse: ^1.2.2 freezed: ^2.4.6 custom_lint: ^0.5.11 riverpod_lint: ^2.1.1 diff --git a/untranslated_messages.json b/untranslated_messages.json index be7d38f1..3696d52e 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -159,7 +159,8 @@ "remote" ], - "tr": [ + "th": [ + "choose_your_language", "enable_connect", "enable_connect_description", "devices", From 6e41b106fa989adee393d3ce2535e75446ad3eea Mon Sep 17 00:00:00 2001 From: Muhammad Brian Abdillah Date: Fri, 12 Apr 2024 11:27:54 +0700 Subject: [PATCH 044/261] feat(android): Filter Device To Force High Frame Rate (#880) * fix(android): filter device to force HFR * fix(android): add failsafe in setHighRefreshRate --- lib/main.dart | 5 +++-- lib/utils/android_utils.dart | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 lib/utils/android_utils.dart diff --git a/lib/main.dart b/lib/main.dart index d6df20ea..b010163b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:catcher_2/catcher_2.dart'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:dart_discord_rpc/dart_discord_rpc.dart'; import 'package:device_preview/device_preview.dart'; import 'package:flutter/foundation.dart'; @@ -38,7 +39,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotube/hooks/configurators/use_init_sys_tray.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; -import 'package:flutter_displaymode/flutter_displaymode.dart'; +import 'package:spotube/utils/android_utils.dart'; Future main(List rawArgs) async { final arguments = await startCLI(rawArgs); @@ -53,7 +54,7 @@ Future main(List rawArgs) async { // force High Refresh Rate on some Android devices (like One Plus) if (DesktopTools.platform.isAndroid) { - await FlutterDisplayMode.setHighRefreshRate(); + await AndroidUtils.setHighRefreshRate(); } if (DesktopTools.platform.isDesktop) { diff --git a/lib/utils/android_utils.dart b/lib/utils/android_utils.dart new file mode 100644 index 00000000..c7ef3d2e --- /dev/null +++ b/lib/utils/android_utils.dart @@ -0,0 +1,39 @@ +import 'package:flutter_displaymode/flutter_displaymode.dart'; + +abstract class AndroidUtils { + + /// Sets the device's display to the highest refresh rate available. + /// + /// This method retrieves the list of supported display modes and the currently active display mode. + /// It then selects the display mode with the highest refresh rate that matches the current resolution. + /// The selected display mode is set as the preferred mode using the FlutterDisplayMode plugin. + /// After setting the new mode, it checks if the system is using the new mode. + /// If the system is not using the new mode, it reverts back to the original mode and returns false. + /// Otherwise, it returns true to indicate that the high refresh rate has been successfully set. + /// + /// Returns true if the high refresh rate is set successfully, false otherwise. + static Future setHighRefreshRate() async { + final List modes = await FlutterDisplayMode.supported; + final DisplayMode activeMode = await FlutterDisplayMode.active; + + DisplayMode newMode = activeMode; + for (final DisplayMode mode in modes) { + if (mode.height == newMode.height && + mode.width == newMode.width && + mode.refreshRate > newMode.refreshRate) { + newMode = mode; + } + } + + await FlutterDisplayMode.setPreferredMode(newMode); + + final display = await FlutterDisplayMode.active; // possibly altered by system + + if (display.refreshRate < newMode.refreshRate) { + await FlutterDisplayMode.setPreferredMode(display); + return false; + } + + return true; + } +} From 2781127da156a3518eadbb47c43f355dd1f3b8cb Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 12 Apr 2024 10:57:09 +0600 Subject: [PATCH 045/261] chore: revert android-utils --- lib/main.dart | 5 ++--- lib/utils/android_utils.dart | 39 ------------------------------------ pubspec.lock | 2 +- 3 files changed, 3 insertions(+), 43 deletions(-) delete mode 100644 lib/utils/android_utils.dart diff --git a/lib/main.dart b/lib/main.dart index b010163b..d6df20ea 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,4 @@ import 'package:catcher_2/catcher_2.dart'; -import 'package:device_info_plus/device_info_plus.dart'; import 'package:dart_discord_rpc/dart_discord_rpc.dart'; import 'package:device_preview/device_preview.dart'; import 'package:flutter/foundation.dart'; @@ -39,7 +38,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotube/hooks/configurators/use_init_sys_tray.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; -import 'package:spotube/utils/android_utils.dart'; +import 'package:flutter_displaymode/flutter_displaymode.dart'; Future main(List rawArgs) async { final arguments = await startCLI(rawArgs); @@ -54,7 +53,7 @@ Future main(List rawArgs) async { // force High Refresh Rate on some Android devices (like One Plus) if (DesktopTools.platform.isAndroid) { - await AndroidUtils.setHighRefreshRate(); + await FlutterDisplayMode.setHighRefreshRate(); } if (DesktopTools.platform.isDesktop) { diff --git a/lib/utils/android_utils.dart b/lib/utils/android_utils.dart deleted file mode 100644 index c7ef3d2e..00000000 --- a/lib/utils/android_utils.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter_displaymode/flutter_displaymode.dart'; - -abstract class AndroidUtils { - - /// Sets the device's display to the highest refresh rate available. - /// - /// This method retrieves the list of supported display modes and the currently active display mode. - /// It then selects the display mode with the highest refresh rate that matches the current resolution. - /// The selected display mode is set as the preferred mode using the FlutterDisplayMode plugin. - /// After setting the new mode, it checks if the system is using the new mode. - /// If the system is not using the new mode, it reverts back to the original mode and returns false. - /// Otherwise, it returns true to indicate that the high refresh rate has been successfully set. - /// - /// Returns true if the high refresh rate is set successfully, false otherwise. - static Future setHighRefreshRate() async { - final List modes = await FlutterDisplayMode.supported; - final DisplayMode activeMode = await FlutterDisplayMode.active; - - DisplayMode newMode = activeMode; - for (final DisplayMode mode in modes) { - if (mode.height == newMode.height && - mode.width == newMode.width && - mode.refreshRate > newMode.refreshRate) { - newMode = mode; - } - } - - await FlutterDisplayMode.setPreferredMode(newMode); - - final display = await FlutterDisplayMode.active; // possibly altered by system - - if (display.refreshRate < newMode.refreshRate) { - await FlutterDisplayMode.setPreferredMode(display); - return false; - } - - return true; - } -} diff --git a/pubspec.lock b/pubspec.lock index 411dc056..bc1f962f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2521,4 +2521,4 @@ packages: version: "2.0.2" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.13.0" + flutter: ">=3.16.0" From 57ccf163114fdf6885a7a8a322134a4845aa45f5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 12 Apr 2024 11:06:03 +0600 Subject: [PATCH 046/261] refactor: rename providers --- lib/collections/intents.dart | 2 +- lib/collections/routes.dart | 3 +-- lib/components/album/album_card.dart | 4 ++-- lib/components/artist/artist_card.dart | 2 +- lib/components/desktop_login/login_form.dart | 3 +-- lib/components/home/sections/new_releases.dart | 2 +- lib/components/library/user_albums.dart | 2 +- lib/components/library/user_artists.dart | 2 +- lib/components/library/user_local_tracks.dart | 6 +++--- lib/components/library/user_playlists.dart | 2 +- lib/components/player/player.dart | 9 ++++----- lib/components/player/player_actions.dart | 8 ++++---- lib/components/player/player_controls.dart | 4 ++-- lib/components/player/player_overlay.dart | 4 ++-- lib/components/player/player_track_details.dart | 2 +- lib/components/player/sibling_tracks_sheet.dart | 2 +- lib/components/playlist/playlist_card.dart | 4 ++-- lib/components/root/bottom_player.dart | 4 ++-- lib/components/root/sidebar.dart | 2 +- .../shared/fallbacks/anonymous_fallback.dart | 2 +- lib/components/shared/heart_button.dart | 2 +- .../shared/track_tile/track_options.dart | 16 ++++++++-------- lib/components/shared/track_tile/track_tile.dart | 2 +- .../sections/body/track_view_body.dart | 4 ++-- .../sections/body/track_view_options.dart | 2 +- .../sections/header/header_actions.dart | 6 +++--- .../sections/header/header_buttons.dart | 4 ++-- .../configurators/use_endless_playback.dart | 11 +++++------ lib/hooks/configurators/use_init_sys_tray.dart | 6 +++--- lib/pages/artist/section/header.dart | 10 ++++------ lib/pages/artist/section/top_tracks.dart | 4 ++-- lib/pages/desktop_login/login_tutorial.dart | 5 ++--- .../playlist_generate_result.dart | 2 +- lib/pages/lyrics/lyrics.dart | 6 +++--- lib/pages/lyrics/mini_lyrics.dart | 10 +++++----- lib/pages/lyrics/plain_lyrics.dart | 2 +- lib/pages/lyrics/synced_lyrics.dart | 4 ++-- lib/pages/mobile_login/mobile_login.dart | 3 +-- lib/pages/root/root_app.dart | 4 ++-- lib/pages/search/search.dart | 5 ++--- lib/pages/search/sections/tracks.dart | 4 ++-- lib/pages/settings/blacklist.dart | 4 ++-- lib/pages/settings/sections/accounts.dart | 4 ++-- lib/pages/track/track.dart | 4 ++-- lib/provider/authentication_provider.dart | 10 +++++----- lib/provider/blacklist_provider.dart | 10 +++++----- lib/provider/connect/server.dart | 4 ++-- .../custom_spotify_endpoint_provider.dart | 2 +- lib/provider/discord_provider.dart | 2 +- .../proxy_playlist/proxy_playlist_provider.dart | 16 ++++++---------- lib/provider/server/active_sourced_track.dart | 2 +- lib/provider/server/server.dart | 2 +- lib/provider/server/sourced_track.dart | 2 +- lib/provider/sleep_timer_provider.dart | 11 ++++------- lib/provider/spotify_provider.dart | 2 +- .../user_preferences_provider.dart | 2 +- 56 files changed, 121 insertions(+), 137 deletions(-) diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 6f42113c..5f60959e 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -92,7 +92,7 @@ class SeekIntent extends Intent { class SeekAction extends Action { @override invoke(intent) async { - final playlist = intent.ref.read(ProxyPlaylistNotifier.provider); + final playlist = intent.ref.read(proxyPlaylistProvider); if (playlist.isFetching) { DirectionalFocusAction().invoke( DirectionalFocusIntent( diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 80067405..5b3a8ed7 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -49,8 +49,7 @@ final routerProvider = Provider((ref) { GoRoute( path: "/", redirect: (context, state) async { - final authNotifier = - ref.read(AuthenticationNotifier.provider.notifier); + final authNotifier = ref.read(authenticationProvider.notifier); final json = await authNotifier.box.get(authNotifier.cacheKey); if (json?["cookie"] == null && diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index ef831d27..a71fbf03 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -28,10 +28,10 @@ class AlbumCard extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(proxyPlaylistProvider); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index ebe18e72..cc8485d5 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -25,7 +25,7 @@ class ArtistCard extends HookConsumerWidget { ), ); final isBlackListed = ref.watch( - BlackListNotifier.provider.select( + blacklistProvider.select( (blacklist) => blacklist.contains( BlacklistedElement.artist(artist.id!, artist.name!), ), diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart index a3deb54a..2949fbae 100644 --- a/lib/components/desktop_login/login_form.dart +++ b/lib/components/desktop_login/login_form.dart @@ -14,8 +14,7 @@ class TokenLoginForm extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final authenticationNotifier = - ref.watch(AuthenticationNotifier.provider.notifier); + final authenticationNotifier = ref.watch(authenticationProvider.notifier); final directCodeController = useTextEditingController(); final mounted = useIsMounted(); diff --git a/lib/components/home/sections/new_releases.dart b/lib/components/home/sections/new_releases.dart index 57af12fd..82bc0e8c 100644 --- a/lib/components/home/sections/new_releases.dart +++ b/lib/components/home/sections/new_releases.dart @@ -11,7 +11,7 @@ class HomeNewReleasesSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); final newReleases = ref.watch(albumReleasesProvider); final newReleasesNotifier = ref.read(albumReleasesProvider.notifier); diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart index f58d6693..43fa0165 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -22,7 +22,7 @@ class UserAlbums extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); final albumsQuery = ref.watch(favoriteAlbumsProvider); final albumsQueryNotifier = ref.watch(favoriteAlbumsProvider.notifier); diff --git a/lib/components/library/user_artists.dart b/lib/components/library/user_artists.dart index de6830c8..83db35c6 100644 --- a/lib/components/library/user_artists.dart +++ b/lib/components/library/user_artists.dart @@ -21,7 +21,7 @@ class UserArtists extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); final artistQuery = ref.watch(followedArtistsProvider); diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 6a953385..f8bd1326 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -138,8 +138,8 @@ class UserLocalTracks extends HookConsumerWidget { List tracks, { LocalTrack? currentTrack, }) async { - final playlist = ref.read(ProxyPlaylistNotifier.provider); - final playback = ref.read(ProxyPlaylistNotifier.notifier); + final playlist = ref.read(proxyPlaylistProvider); + final playback = ref.read(proxyPlaylistProvider.notifier); currentTrack ??= tracks.first; final isPlaylistPlaying = playlist.containsTracks(tracks); if (!isPlaylistPlaying) { @@ -158,7 +158,7 @@ class UserLocalTracks extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final sortBy = useState(SortBy.none); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(proxyPlaylistProvider); final trackSnapshot = ref.watch(localTracksProvider); final isPlaylistPlaying = playlist.containsTracks(trackSnapshot.asData?.value ?? []); diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 3ff028b6..563541de 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -26,7 +26,7 @@ class UserPlaylists extends HookConsumerWidget { Widget build(BuildContext context, ref) { final searchText = useState(''); - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); final playlistsQuery = ref.watch(favoritePlaylistsProvider); final playlistsQueryNotifier = diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 054e6706..7d61aa85 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -43,8 +43,8 @@ class PlayerView extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); - final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( + final auth = ref.watch(authenticationProvider); + final currentTrack = ref.watch(proxyPlaylistProvider.select( (value) => value.activeTrack, )); final isLocalTrack = currentTrack is LocalTrack; @@ -307,12 +307,11 @@ class PlayerView extends HookConsumerWidget { builder: (context) => Consumer( builder: (context, ref, _) { final playlist = ref.watch( - ProxyPlaylistNotifier - .provider, + proxyPlaylistProvider, ); final playlistNotifier = ref.read( - ProxyPlaylistNotifier + proxyPlaylistProvider .notifier, ); return PlayerQueue diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 4102e2ba..d28c3900 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -33,7 +33,7 @@ class PlayerActions extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(proxyPlaylistProvider); final isLocalTrack = playlist.activeTrack is LocalTrack; ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); @@ -46,9 +46,9 @@ class PlayerActions extends HookConsumerWidget { ]); final localTracks = [] /* ref.watch(localTracksProvider).value */; - final auth = ref.watch(AuthenticationNotifier.provider); - final sleepTimer = ref.watch(SleepTimerNotifier.provider); - final sleepTimerNotifier = ref.watch(SleepTimerNotifier.notifier); + final auth = ref.watch(authenticationProvider); + final sleepTimer = ref.watch(sleepTimerProvider); + final sleepTimerNotifier = ref.watch(sleepTimerProvider.notifier); final isDownloaded = useMemoized(() { return localTracks.any( diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 0190e2e6..7683de19 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -43,8 +43,8 @@ class PlayerControls extends HookConsumerWidget { SeekIntent: SeekAction(), }, []); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(proxyPlaylistProvider); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 37ae49cf..168e022d 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -24,8 +24,8 @@ class PlayerOverlay extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlist = ref.watch(proxyPlaylistProvider); final canShow = playlist.activeTrack != null; final playing = diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index 65e40fe6..4746fe51 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -21,7 +21,7 @@ class PlayerTrackDetails extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final playback = ref.watch(ProxyPlaylistNotifier.provider); + final playback = ref.watch(proxyPlaylistProvider); return Row( children: [ diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index eef34be6..99b7b430 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -52,7 +52,7 @@ class SiblingTracksSheet extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(proxyPlaylistProvider); final preferences = ref.watch(userPreferencesProvider); final isSearching = useState(false); diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 3777a1cb..ae6f20e5 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -20,8 +20,8 @@ class PlaylistCard extends HookConsumerWidget { }); @override Widget build(BuildContext context, ref) { - final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlistQueue = ref.watch(proxyPlaylistProvider); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; bool isPlaylistPlaying = useMemoized( diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 1cdf72b5..06250131 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -31,8 +31,8 @@ class BottomPlayer extends HookConsumerWidget { final logger = getLogger(BottomPlayer); @override Widget build(BuildContext context, ref) { - final auth = ref.watch(AuthenticationNotifier.provider); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final auth = ref.watch(authenticationProvider); + final playlist = ref.watch(proxyPlaylistProvider); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 903e812e..2a9e3af8 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -249,7 +249,7 @@ class SidebarFooter extends HookConsumerWidget { placeholder: ImagePlaceholder.artist, ); - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); if (mediaQuery.mdAndDown) { return IconButton( diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/shared/fallbacks/anonymous_fallback.dart index ace7ec64..2f06b0b6 100644 --- a/lib/components/shared/fallbacks/anonymous_fallback.dart +++ b/lib/components/shared/fallbacks/anonymous_fallback.dart @@ -14,7 +14,7 @@ class AnonymousFallback extends ConsumerWidget { @override Widget build(BuildContext context, ref) { - final isLoggedIn = ref.watch(AuthenticationNotifier.provider) != null; + final isLoggedIn = ref.watch(authenticationProvider) != null; if (isLoggedIn && child != null) return child!; return Center( diff --git a/lib/components/shared/heart_button.dart b/lib/components/shared/heart_button.dart index 9475f9e3..c296d7a9 100644 --- a/lib/components/shared/heart_button.dart +++ b/lib/components/shared/heart_button.dart @@ -25,7 +25,7 @@ class HeartButton extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); if (auth == null) return const SizedBox.shrink(); diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 29349602..a9ec36b9 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -95,8 +95,8 @@ class TrackOptions extends HookConsumerWidget { WidgetRef ref, Track track, ) async { - final playback = ref.read(ProxyPlaylistNotifier.notifier); - final playlist = ref.read(ProxyPlaylistNotifier.provider); + final playback = ref.read(proxyPlaylistProvider.notifier); + final playlist = ref.read(proxyPlaylistProvider); final spotify = ref.read(spotifyProvider); final query = "${track.name} Radio"; final pages = @@ -159,12 +159,12 @@ class TrackOptions extends HookConsumerWidget { final router = GoRouter.of(context); final ThemeData(:colorScheme) = Theme.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playback = ref.watch(ProxyPlaylistNotifier.notifier); - final auth = ref.watch(AuthenticationNotifier.provider); + final playlist = ref.watch(proxyPlaylistProvider); + final playback = ref.watch(proxyPlaylistProvider.notifier); + final auth = ref.watch(authenticationProvider); ref.watch(downloadManagerProvider); final downloadManager = ref.watch(downloadManagerProvider.notifier); - final blacklist = ref.watch(BlackListNotifier.provider); + final blacklist = ref.watch(blacklistProvider); final me = ref.watch(meProvider); final favorites = useTrackToggleLike(track, ref); @@ -257,11 +257,11 @@ class TrackOptions extends HookConsumerWidget { break; case TrackOptionValue.blacklist: if (isBlackListed) { - ref.read(BlackListNotifier.provider.notifier).remove( + ref.read(blacklistProvider.notifier).remove( BlacklistedElement.track(track.id!, track.name!), ); } else { - ref.read(BlackListNotifier.provider.notifier).add( + ref.read(blacklistProvider.notifier).add( BlacklistedElement.track(track.id!, track.name!), ); } diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 5a075502..30912da2 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -52,7 +52,7 @@ class TrackTile extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final blacklist = ref.watch(BlackListNotifier.provider); + final blacklist = ref.watch(blacklistProvider); final isBlackListed = useMemoized( () => blacklist.contains( diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index 80368445..f576ba0a 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -26,8 +26,8 @@ class TrackViewBodySection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(proxyPlaylistProvider); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final props = InheritedTrackView.of(context); final trackViewState = ref.watch(trackViewProvider(props.tracks)); diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart index 5560ef3f..ff92b663 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart @@ -22,7 +22,7 @@ class TrackViewBodyOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final audioSource = ref.watch(userPreferencesProvider.select((s) => s.audioSource)); diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart index a16dd750..f6880485 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -18,8 +18,8 @@ class TrackViewHeaderActions extends HookConsumerWidget { Widget build(BuildContext context, ref) { final props = InheritedTrackView.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(proxyPlaylistProvider); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); @@ -27,7 +27,7 @@ class TrackViewHeaderActions extends HookConsumerWidget { final scaffoldMessenger = ScaffoldMessenger.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); return Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index 71e6c9f5..50eeb747 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -26,8 +26,8 @@ class TrackViewHeaderButtons extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final props = InheritedTrackView.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(proxyPlaylistProvider); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart index 3cd55e40..98f38165 100644 --- a/lib/hooks/configurators/use_endless_playback.dart +++ b/lib/hooks/configurators/use_endless_playback.dart @@ -9,21 +9,20 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/services/audio_player/audio_player.dart'; void useEndlessPlayback(WidgetRef ref) { - final auth = ref.watch(AuthenticationNotifier.provider); - final playback = ref.watch(ProxyPlaylistNotifier.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final auth = ref.watch(authenticationProvider); + final playback = ref.watch(proxyPlaylistProvider.notifier); + final playlist = ref.watch(proxyPlaylistProvider); final spotify = ref.watch(spotifyProvider); final endlessPlayback = ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback)); - useEffect( () { if (!endlessPlayback || auth == null) return null; void listener(int index) async { try { - final playlist = ref.read(ProxyPlaylistNotifier.provider); + final playlist = ref.read(proxyPlaylistProvider); if (index != playlist.tracks.length - 1) return; final track = playlist.tracks.last; @@ -57,7 +56,7 @@ void useEndlessPlayback(WidgetRef ref) { await playback.addTracks( tracks.toList() ..removeWhere((e) { - final playlist = ref.read(ProxyPlaylistNotifier.provider); + final playlist = ref.read(proxyPlaylistProvider); final isDuplicate = playlist.tracks.any((t) => t.id == e.id); return e.id == track.id || isDuplicate; }), diff --git a/lib/hooks/configurators/use_init_sys_tray.dart b/lib/hooks/configurators/use_init_sys_tray.dart index 8080bea6..0bce6727 100644 --- a/lib/hooks/configurators/use_init_sys_tray.dart +++ b/lib/hooks/configurators/use_init_sys_tray.dart @@ -15,8 +15,8 @@ void useInitSysTray(WidgetRef ref) { final initializeMenu = useCallback(() async { systemTray.value?.destroy(); - final playlist = ref.read(ProxyPlaylistNotifier.provider); - final playlistQueue = ref.read(ProxyPlaylistNotifier.notifier); + final playlist = ref.read(proxyPlaylistProvider); + final playlistQueue = ref.read(proxyPlaylistProvider.notifier); final preferences = ref.read(userPreferencesProvider); if (!preferences.showSystemTrayIcon) { await systemTray.value?.destroy(); @@ -105,7 +105,7 @@ void useInitSysTray(WidgetRef ref) { useReassemble(initializeMenu); ref.listen( - ProxyPlaylistNotifier.provider, + proxyPlaylistProvider, (previous, next) { initializeMenu(); }, diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index e5cb8900..5bad674e 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -38,8 +38,8 @@ class ArtistPageHeader extends HookConsumerWidget { xxl: textTheme.titleMedium, ); - final auth = ref.watch(AuthenticationNotifier.provider); - final blacklist = ref.watch(BlackListNotifier.provider); + final auth = ref.watch(authenticationProvider); + final blacklist = ref.watch(blacklistProvider); final isBlackListed = blacklist.contains( BlacklistedElement.artist(artistId, artist.name!), ); @@ -187,14 +187,12 @@ class ArtistPageHeader extends HookConsumerWidget { ), onPressed: () async { if (isBlackListed) { - ref - .read(BlackListNotifier.provider.notifier) - .remove( + ref.read(blacklistProvider.notifier).remove( BlacklistedElement.artist( artist.id!, artist.name!), ); } else { - ref.read(BlackListNotifier.provider.notifier).add( + ref.read(blacklistProvider.notifier).add( BlacklistedElement.artist( artist.id!, artist.name!), ); diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 9dec5f7c..9d407899 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -21,8 +21,8 @@ class ArtistPageTopTracks extends HookConsumerWidget { final theme = Theme.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(proxyPlaylistProvider); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final topTracksQuery = ref.watch(artistTopTracksProvider(artistId)); final isPlaylistPlaying = playlist.containsTracks( diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index e6a4cf9a..83b04af1 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -16,9 +16,8 @@ class LoginTutorial extends ConsumerWidget { @override Widget build(BuildContext context, ref) { - ref.watch(AuthenticationNotifier.provider); - final authenticationNotifier = - ref.watch(AuthenticationNotifier.provider.notifier); + ref.watch(authenticationProvider); + final authenticationNotifier = ref.watch(authenticationProvider.notifier); final key = GlobalKey>(); final theme = Theme.of(context); diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 5390c337..01b73267 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -25,7 +25,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final router = GoRouter.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final generatedPlaylist = ref.watch(generatePlaylistProvider(state)); diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index a0db7178..ca13864a 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -28,7 +28,7 @@ class LyricsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(proxyPlaylistProvider); String albumArt = useMemoized( () => (playlist.activeTrack?.album?.images).asUrlString( index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, @@ -60,7 +60,7 @@ class LyricsPage extends HookConsumerWidget { const Spacer(), Consumer( builder: (context, ref, child) { - final playback = ref.watch(ProxyPlaylistNotifier.provider); + final playback = ref.watch(proxyPlaylistProvider); final lyric = ref.watch(syncedLyricsProvider(playback.activeTrack)); final providerName = lyric.asData?.value.provider; @@ -80,7 +80,7 @@ class LyricsPage extends HookConsumerWidget { ), ); - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); if (auth == null) { return Scaffold( diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 310df75c..1e4d4641 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -29,7 +29,7 @@ class MiniLyricsPage extends HookConsumerWidget { final update = useForceUpdate(); final wasMaximized = useRef(false); - final playlistQueue = ref.watch(ProxyPlaylistNotifier.provider); + final playlistQueue = ref.watch(proxyPlaylistProvider); final areaActive = useState(false); final hoverMode = useState(true); @@ -42,7 +42,7 @@ class MiniLyricsPage extends HookConsumerWidget { return null; }, []); - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); if (auth == null) { return const Scaffold( @@ -222,15 +222,15 @@ class MiniLyricsPage extends HookConsumerWidget { ), builder: (context) { return Consumer(builder: (context, ref, _) { - final playlist = ref - .watch(ProxyPlaylistNotifier.provider); + final playlist = + ref.watch(proxyPlaylistProvider); return PlayerQueue .fromProxyPlaylistNotifier( floating: true, playlist: playlist, notifier: ref - .read(ProxyPlaylistNotifier.notifier), + .read(proxyPlaylistProvider.notifier), ); }); }, diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 2c0df0aa..b3a55a27 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -27,7 +27,7 @@ class PlainLyrics extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(proxyPlaylistProvider); final lyricsQuery = ref.watch(syncedLyricsProvider(playlist.activeTrack)); final mediaQuery = MediaQuery.of(context); final textTheme = Theme.of(context).textTheme; diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 3b158d47..0e0fff2e 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -32,7 +32,7 @@ class SyncedLyrics extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(proxyPlaylistProvider); final mediaQuery = MediaQuery.of(context); final controller = useAutoScrollController(); @@ -54,7 +54,7 @@ class SyncedLyrics extends HookConsumerWidget { final textTheme = Theme.of(context).textTheme; ref.listen( - ProxyPlaylistNotifier.provider.select((s) => s.activeTrack), + proxyPlaylistProvider.select((s) => s.activeTrack), (previous, next) { controller.scrollToIndex(0); ref.read(syncedLyricsDelayProvider.notifier).state = 0; diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 6260e284..1afca919 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -13,8 +13,7 @@ class WebViewLogin extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final mounted = useIsMounted(); - final authenticationNotifier = - ref.watch(AuthenticationNotifier.provider.notifier); + final authenticationNotifier = ref.watch(authenticationProvider.notifier); if (kIsDesktop) { const Scaffold( diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 6ce74e53..56ea43a6 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -221,9 +221,9 @@ class RootApp extends HookConsumerWidget { ), child: Consumer( builder: (context, ref, _) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = - ref.read(ProxyPlaylistNotifier.notifier); + ref.read(proxyPlaylistProvider.notifier); return PlayerQueue.fromProxyPlaylistNotifier( floating: true, diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index c58b8df3..e9ada236 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -34,9 +34,8 @@ class SearchPage extends HookConsumerWidget { final searchTerm = ref.watch(searchTermStateProvider); final controller = useSearchController(); - ref.watch(AuthenticationNotifier.provider); - final authenticationNotifier = - ref.watch(AuthenticationNotifier.provider.notifier); + ref.watch(authenticationProvider); + final authenticationNotifier = ref.watch(authenticationProvider.notifier); final mediaQuery = MediaQuery.of(context); final searchTrack = ref.watch(searchProvider(SearchType.track)); diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 2152cc45..48dabc13 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -24,8 +24,8 @@ class SearchTracksSection extends HookConsumerWidget { ref.watch(searchProvider(SearchType.track).notifier); final tracks = searchTrack.asData?.value.items.cast() ?? []; - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.provider.notifier); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlist = ref.watch(proxyPlaylistProvider); final theme = Theme.of(context); return Column( diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 45ce76d9..9dd85c50 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -16,7 +16,7 @@ class BlackListPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final controller = useScrollController(); - final blacklist = ref.watch(BlackListNotifier.provider); + final blacklist = ref.watch(blacklistProvider); final searchText = useState(""); final filteredBlacklist = useMemoized( @@ -74,7 +74,7 @@ class BlackListPage extends HookConsumerWidget { icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), onPressed: () { ref - .read(BlackListNotifier.provider.notifier) + .read(blacklistProvider.notifier) .remove(filteredBlacklist.elementAt(index)); }, ), diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index bded71b3..ab3a7c92 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -15,7 +15,7 @@ class SettingsAccountSection extends HookConsumerWidget { @override Widget build(context, ref) { final theme = Theme.of(context); - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); final scrobbler = ref.watch(scrobblerProvider); final router = GoRouter.of(context); @@ -86,7 +86,7 @@ class SettingsAccountSection extends HookConsumerWidget { trailing: FilledButton( style: logoutBtnStyle, onPressed: () async { - ref.read(AuthenticationNotifier.provider.notifier).logout(); + ref.read(authenticationProvider.notifier).logout(); GoRouter.of(context).pop(); }, child: Text(context.l10n.logout), diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index 829256d4..fc90d19a 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -32,8 +32,8 @@ class TrackPage extends HookConsumerWidget { final ThemeData(:textTheme, :colorScheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final playlist = ref.watch(proxyPlaylistProvider); + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); final isActive = playlist.activeTrack?.id == trackId; diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index 0258058b..f7549ad7 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -95,11 +95,6 @@ class AuthenticationCredentials { class AuthenticationNotifier extends PersistedStateNotifier { - static final provider = - StateNotifierProvider( - (ref) => AuthenticationNotifier(), - ); - bool get isLoggedIn => state != null; AuthenticationNotifier() : super(null, "authentication", encrypted: true); @@ -154,3 +149,8 @@ class AuthenticationNotifier return state?.toJson() ?? {}; } } + +final authenticationProvider = + StateNotifierProvider( + (ref) => AuthenticationNotifier(), +); diff --git a/lib/provider/blacklist_provider.dart b/lib/provider/blacklist_provider.dart index 1d4edebf..4f488112 100644 --- a/lib/provider/blacklist_provider.dart +++ b/lib/provider/blacklist_provider.dart @@ -43,11 +43,6 @@ class BlackListNotifier extends PersistedStateNotifier> { BlackListNotifier() : super({}, "blacklist"); - static final provider = - StateNotifierProvider>( - (ref) => BlackListNotifier(), - ); - void add(BlacklistedElement element) { state = state.union({element}); } @@ -106,3 +101,8 @@ class BlackListNotifier return {'blacklist': state.map((e) => e.toJson()).toList()}; } } + +final blacklistProvider = + StateNotifierProvider>((ref) { + return BlackListNotifier(); +}); diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart index 0469e3f5..ebf53e43 100644 --- a/lib/provider/connect/server.dart +++ b/lib/provider/connect/server.dart @@ -31,7 +31,7 @@ final connectServerProvider = FutureProvider((ref) async { ref.watch(userPreferencesProvider.select((s) => s.enableConnect)); final resolvedService = await ref .watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); - final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier); + final playbackNotifier = ref.read(proxyPlaylistProvider.notifier); if (!enabled || resolvedService != null) { return null; @@ -57,7 +57,7 @@ final connectServerProvider = FutureProvider((ref) async { _connectClientStreamController.add(origin); ref.listen( - ProxyPlaylistNotifier.provider, + proxyPlaylistProvider, (previous, next) { channel.sink.add( WebSocketQueueEvent(next).toJson(), diff --git a/lib/provider/custom_spotify_endpoint_provider.dart b/lib/provider/custom_spotify_endpoint_provider.dart index 7a4c5533..4634549a 100644 --- a/lib/provider/custom_spotify_endpoint_provider.dart +++ b/lib/provider/custom_spotify_endpoint_provider.dart @@ -5,6 +5,6 @@ import 'package:spotube/services/custom_spotify_endpoints/spotify_endpoints.dart final customSpotifyEndpointProvider = Provider((ref) { ref.watch(spotifyProvider); - final auth = ref.watch(AuthenticationNotifier.provider); + final auth = ref.watch(authenticationProvider); return CustomSpotifyEndpoints(auth?.accessToken ?? ""); }); diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index e07e2d3b..ca8eecfa 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -57,7 +57,7 @@ final discordProvider = ChangeNotifierProvider( (ref) { final isEnabled = ref.watch(userPreferencesProvider.select((s) => s.discordPresence)); - final playback = ref.read(ProxyPlaylistNotifier.provider); + final playback = ref.read(proxyPlaylistProvider); final discord = Discord(isEnabled); if (playback.activeTrack != null) { diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index bf039395..060ada1b 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -28,18 +28,9 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); UserPreferences get preferences => ref.read(userPreferencesProvider); ProxyPlaylist get playlist => state; - BlackListNotifier get blacklist => - ref.read(BlackListNotifier.provider.notifier); + BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier); Discord get discord => ref.read(discordProvider); - static final provider = - StateNotifierProvider( - (ref) => ProxyPlaylistNotifier(ref), - ); - - static AlwaysAliveRefreshable get notifier => - provider.notifier; - List _subscriptions = []; ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") { @@ -230,3 +221,8 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { super.dispose(); } } + +final proxyPlaylistProvider = + StateNotifierProvider( + (ref) => ProxyPlaylistNotifier(ref), +); diff --git a/lib/provider/server/active_sourced_track.dart b/lib/provider/server/active_sourced_track.dart index 6ecd67b4..410b788c 100644 --- a/lib/provider/server/active_sourced_track.dart +++ b/lib/provider/server/active_sourced_track.dart @@ -28,7 +28,7 @@ class ActiveSourcedTrackNotifier extends Notifier { state = newTrack; await audioPlayer.pause(); - final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier); + final playbackNotifier = ref.read(proxyPlaylistProvider.notifier); final oldActiveIndex = audioPlayer.currentIndex; await playbackNotifier.addTracksAtFirst([newTrack]); diff --git a/lib/provider/server/server.dart b/lib/provider/server/server.dart index 48f32a3c..009cc534 100644 --- a/lib/provider/server/server.dart +++ b/lib/provider/server/server.dart @@ -20,7 +20,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class PlaybackServer { final Ref ref; UserPreferences get userPreferences => ref.read(userPreferencesProvider); - ProxyPlaylist get playlist => ref.read(ProxyPlaylistNotifier.provider); + ProxyPlaylist get playlist => ref.read(proxyPlaylistProvider); final Logger logger; final Dio dio; diff --git a/lib/provider/server/sourced_track.dart b/lib/provider/server/sourced_track.dart index ffa62213..82c7ddcd 100644 --- a/lib/provider/server/sourced_track.dart +++ b/lib/provider/server/sourced_track.dart @@ -12,7 +12,7 @@ final sourcedTrackProvider = } ref.listen( - ProxyPlaylistNotifier.provider, + proxyPlaylistProvider, (old, next) { if (next.tracks.isEmpty || next.tracks.none((element) => element.id == track.id)) { diff --git a/lib/provider/sleep_timer_provider.dart b/lib/provider/sleep_timer_provider.dart index 32678ac7..53386e49 100644 --- a/lib/provider/sleep_timer_provider.dart +++ b/lib/provider/sleep_timer_provider.dart @@ -8,13 +8,6 @@ class SleepTimerNotifier extends StateNotifier { Timer? _timer; - static final provider = StateNotifierProvider( - (ref) => SleepTimerNotifier(), - ); - - static AlwaysAliveRefreshable get notifier => - provider.notifier; - void setSleepTimer(Duration duration) { state = duration; @@ -29,3 +22,7 @@ class SleepTimerNotifier extends StateNotifier { _timer?.cancel(); } } + +final sleepTimerProvider = StateNotifierProvider( + (ref) => SleepTimerNotifier(), +); diff --git a/lib/provider/spotify_provider.dart b/lib/provider/spotify_provider.dart index 2675a9f7..f8b6e044 100644 --- a/lib/provider/spotify_provider.dart +++ b/lib/provider/spotify_provider.dart @@ -6,7 +6,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/primitive_utils.dart'; final spotifyProvider = Provider((ref) { - final authState = ref.watch(AuthenticationNotifier.provider); + final authState = ref.watch(authenticationProvider); final anonCred = PrimitiveUtils.getRandomElement(Env.spotifySecrets); if (authState == null) { diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index 42b38746..a1e247b2 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -52,7 +52,7 @@ class UserPreferencesNotifier extends PersistedStateNotifier { if (!sync) { ref.read(paletteProvider.notifier).state = null; } else { - ref.read(ProxyPlaylistNotifier.notifier).updatePalette(); + ref.read(proxyPlaylistProvider.notifier).updatePalette(); } } From f82253c6ba8a120cee82bf707d69b875cdc1b055 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 13 Apr 2024 10:22:59 +0600 Subject: [PATCH 047/261] refactor: show devices in sidebar in big screens --- lib/components/connect/connect_device.dart | 33 +++++++++- lib/components/root/sidebar.dart | 77 ++++++++++++---------- lib/main.dart | 2 +- lib/pages/home/home.dart | 30 +++++---- 4 files changed, 94 insertions(+), 48 deletions(-) diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart index 8ece074f..14243fa8 100644 --- a/lib/components/connect/connect_device.dart +++ b/lib/components/connect/connect_device.dart @@ -7,7 +7,9 @@ import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/utils/service_utils.dart'; class ConnectDeviceButton extends HookConsumerWidget { - const ConnectDeviceButton({super.key}); + final bool _sidebar; + const ConnectDeviceButton({super.key}) : _sidebar = false; + const ConnectDeviceButton.sidebar({super.key}) : _sidebar = true; @override Widget build(BuildContext context, ref) { @@ -15,6 +17,35 @@ class ConnectDeviceButton extends HookConsumerWidget { final pixelRatio = MediaQuery.of(context).devicePixelRatio; final connectClients = ref.watch(connectClientsProvider); + if (_sidebar) { + return SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () { + ServiceUtils.push(context, "/connect"); + }, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(5), + ), + child: Row( + children: [ + Text(context.l10n.devices), + if (connectClients.asData?.value.services.isNotEmpty == true) + Text( + " (${connectClients.asData?.value.services.length})", + ), + const Spacer(), + const Icon(SpotubeIcons.speaker), + const Gap(5), + ], + ), + ), + ); + } + return SizedBox( height: 40 * pixelRatio, child: Stack( diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 2a9e3af8..f49a9c0d 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.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:flutter/material.dart'; @@ -8,6 +9,7 @@ import 'package:sidebarx/sidebarx.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; @@ -261,43 +263,50 @@ class SidebarFooter extends HookConsumerWidget { return Container( padding: const EdgeInsets.only(left: 12), width: 250, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( children: [ - if (auth != null && data == null) - const CircularProgressIndicator() - else if (data != null) - Flexible( - child: Row( - children: [ - CircleAvatar( - backgroundImage: UniversalImage.imageProvider(avatarImg), - onBackgroundImageError: (exception, stackTrace) => - Assets.userPlaceholder.image( - height: 16, - width: 16, - ), + const ConnectDeviceButton.sidebar(), + const Gap(10), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (auth != null && data == null) + const CircularProgressIndicator() + else if (data != null) + Flexible( + child: Row( + children: [ + CircleAvatar( + backgroundImage: + UniversalImage.imageProvider(avatarImg), + onBackgroundImageError: (exception, stackTrace) => + Assets.userPlaceholder.image( + height: 16, + width: 16, + ), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + data.displayName ?? context.l10n.guest, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + ), + ], ), - const SizedBox(width: 10), - Flexible( - child: Text( - data.displayName ?? context.l10n.guest, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - style: theme.textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ], + ), + IconButton( + icon: const Icon(SpotubeIcons.settings), + onPressed: () { + Sidebar.goToSettings(context); + }, ), - ), - IconButton( - icon: const Icon(SpotubeIcons.settings), - onPressed: () { - Sidebar.goToSettings(context); - }, + ], ), ], ), diff --git a/lib/main.dart b/lib/main.dart index d6df20ea..95724c79 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -230,7 +230,7 @@ class SpotubeState extends ConsumerState { builder: (context, child) { return DevicePreview.appBuilder( context, - DesktopTools.platform.isDesktop + DesktopTools.platform.isDesktop && !DesktopTools.platform.isMacOS ? DragToResizeArea(child: child!) : child, ); diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 487ceb4c..7b70794d 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -11,6 +11,8 @@ 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/extensions/constrains.dart'; +import 'package:spotube/utils/platform.dart'; class HomePage extends HookConsumerWidget { const HomePage({super.key}); @@ -18,6 +20,7 @@ class HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final controller = useScrollController(); + final mediaQuery = MediaQuery.of(context); return SafeArea( bottom: false, @@ -25,18 +28,21 @@ class HomePage extends HookConsumerWidget { body: CustomScrollView( controller: controller, slivers: [ - PageWindowTitleBar.sliver( - pinned: DesktopTools.platform.isDesktop, - actions: [ - const ConnectDeviceButton(), - const Gap(10), - IconButton.filledTonal( - icon: const Icon(SpotubeIcons.user), - onPressed: () {}, - ), - const Gap(10), - ], - ), + if (mediaQuery.mdAndDown) + PageWindowTitleBar.sliver( + pinned: DesktopTools.platform.isDesktop, + actions: [ + const ConnectDeviceButton(), + const Gap(10), + IconButton.filledTonal( + icon: const Icon(SpotubeIcons.user), + onPressed: () {}, + ), + const Gap(10), + ], + ) + else if (kIsMacOS) + const SliverGap(10), const HomeGenresSection(), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), From 39e97eef34d87348a264843e145f31f82832d12e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 13 Apr 2024 13:05:41 +0600 Subject: [PATCH 048/261] feat: add user profile page --- lib/collections/routes.dart | 35 +++-- lib/components/root/sidebar.dart | 49 ++++--- lib/pages/home/home.dart | 29 +++- lib/pages/profile/profile.dart | 144 ++++++++++++++++++++ lib/services/audio_player/audio_player.dart | 71 +--------- 5 files changed, 220 insertions(+), 108 deletions(-) create mode 100644 lib/pages/profile/profile.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 5b3a8ed7..aeeb4837 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -18,6 +18,7 @@ import 'package:spotube/pages/library/playlist_generate/playlist_generate_result import 'package:spotube/pages/lyrics/mini_lyrics.dart'; import 'package:spotube/pages/playlist/liked_playlist.dart'; import 'package:spotube/pages/playlist/playlist.dart'; +import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; @@ -175,20 +176,26 @@ final routerProvider = Provider((ref) { }, ), GoRoute( - path: "/connect", - pageBuilder: (context, state) => const SpotubePage( - child: ConnectPage(), - ), - routes: [ - GoRoute( - path: "control", - pageBuilder: (context, state) { - return const SpotubePage( - child: ConnectControlPage(), - ); - }, - ) - ]) + path: "/connect", + pageBuilder: (context, state) => const SpotubePage( + child: ConnectPage(), + ), + routes: [ + GoRoute( + path: "control", + pageBuilder: (context, state) { + return const SpotubePage( + child: ConnectControlPage(), + ); + }, + ) + ], + ), + GoRoute( + path: "/profile", + pageBuilder: (context, state) => + const SpotubePage(child: ProfilePage()), + ) ], ), GoRoute( diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index f49a9c0d..a100ca8e 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -23,6 +23,7 @@ import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/service_utils.dart'; class Sidebar extends HookConsumerWidget { final int? selectedIndex; @@ -275,29 +276,35 @@ class SidebarFooter extends HookConsumerWidget { const CircularProgressIndicator() else if (data != null) Flexible( - child: Row( - children: [ - CircleAvatar( - backgroundImage: - UniversalImage.imageProvider(avatarImg), - onBackgroundImageError: (exception, stackTrace) => - Assets.userPlaceholder.image( - height: 16, - width: 16, + child: InkWell( + onTap: () { + ServiceUtils.push(context, "/profile"); + }, + borderRadius: BorderRadius.circular(30), + child: Row( + children: [ + CircleAvatar( + backgroundImage: + UniversalImage.imageProvider(avatarImg), + onBackgroundImageError: (exception, stackTrace) => + Assets.userPlaceholder.image( + height: 16, + width: 16, + ), ), - ), - const SizedBox(width: 10), - Flexible( - child: Text( - data.displayName ?? context.l10n.guest, - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - style: theme.textTheme.bodyMedium - ?.copyWith(fontWeight: FontWeight.bold), + const SizedBox(width: 10), + Flexible( + child: Text( + data.displayName ?? context.l10n.guest, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), ), - ), - ], + ], + ), ), ), IconButton( diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 7b70794d..5b959621 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,16 +3,19 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/friends.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/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/service_utils.dart'; class HomePage extends HookConsumerWidget { const HomePage({super.key}); @@ -34,10 +37,26 @@ class HomePage extends HookConsumerWidget { actions: [ const ConnectDeviceButton(), const Gap(10), - IconButton.filledTonal( - icon: const Icon(SpotubeIcons.user), - onPressed: () {}, - ), + Consumer(builder: (context, ref, _) { + final me = ref.watch(meProvider); + final meData = me.asData?.value; + + return IconButton( + icon: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (meData?.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + ), + onPressed: () { + ServiceUtils.push(context, "/profile"); + }, + ); + }), const Gap(10), ], ) diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart new file mode 100644 index 00000000..52b69835 --- /dev/null +++ b/lib/pages/profile/profile.dart @@ -0,0 +1,144 @@ +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:sliver_tools/sliver_tools.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/spotify_markets.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class ProfilePage extends HookConsumerWidget { + const ProfilePage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme) = Theme.of(context); + + final me = ref.watch(meProvider); + final meData = me.asData?.value ?? FakeData.user; + + final userProperties = useMemoized( + () => { + "Email": meData.email ?? "N/A", + "Followers": meData.followers?.total.toString() ?? "N/A", + "Birthday": meData.birthdate ?? "Not born", + "Country": spotifyMarkets + .firstWhere((market) => market.$1 == meData.country) + .$2, + "Subscription": meData.product ?? "Hacker", + }, + [meData], + ); + + return SafeArea( + child: Scaffold( + appBar: const PageWindowTitleBar( + title: Text("Profile"), + titleSpacing: 0, + automaticallyImplyLeading: true, + centerTitle: false, + ), + body: Skeletonizer( + enabled: me.isLoading, + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(600), + child: UniversalImage( + path: meData.images.asUrlString( + index: 1, + placeholder: ImagePlaceholder.artist, + ), + width: 300, + height: 300, + fit: BoxFit.cover, + ), + ), + ], + ), + ), + const SliverGap(10), + SliverToBoxAdapter( + child: Text( + meData.displayName ?? "No Name", + style: textTheme.titleLarge, + textAlign: TextAlign.center, + ), + ), + const SliverGap(20), + SliverCrossAxisConstrained( + maxCrossAxisExtent: 500, + child: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + label: const Text("Edit"), + icon: const Icon(SpotubeIcons.edit), + onPressed: () { + launchUrlString( + "https://www.spotify.com/account/profile/", + mode: LaunchMode.externalApplication, + ); + }, + ), + ], + ), + ), + ), + SliverCrossAxisConstrained( + maxCrossAxisExtent: 500, + child: SliverToBoxAdapter( + child: Card( + margin: const EdgeInsets.all(10), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Table( + columnWidths: const { + 0: FixedColumnWidth(110), + }, + children: [ + for (final MapEntry(:key, :value) + in userProperties.entries) + TableRow( + children: [ + TableCell( + child: Padding( + padding: const EdgeInsets.all(6), + child: Text( + key, + style: textTheme.titleSmall, + ), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(6), + child: Text(value), + ), + ), + ], + ) + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index d5ebddb4..a81c6c95 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -7,7 +7,6 @@ import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/services/audio_player/custom_player.dart'; -// import 'package:just_audio/just_audio.dart' as ja; import 'dart:async'; import 'package:media_kit/media_kit.dart' as mk; @@ -43,7 +42,6 @@ class SpotubeMedia extends mk.Media { abstract class AudioPlayerInterface { final CustomPlayer _mkPlayer; - // final ja.AudioPlayer? _justAudxio; AudioPlayerInterface() : _mkPlayer = CustomPlayer( @@ -51,9 +49,7 @@ abstract class AudioPlayerInterface { title: "Spotube", logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error, ), - ) - // _justAudio = !_mkSupportedPlatform ? ja.AudioPlayer() : null - { + ) { _mkPlayer.stream.error.listen((event) { Catcher2.reportCheckedError(event, StackTrace.current); }); @@ -61,33 +57,19 @@ abstract class AudioPlayerInterface { /// Whether the current platform supports the audioplayers plugin static const bool _mkSupportedPlatform = true; - // DesktopTools.platform.isWindows || DesktopTools.platform.isLinux; bool get mkSupportedPlatform => _mkSupportedPlatform; Future get duration async { return _mkPlayer.state.duration; - // if (mkSupportedPlatform) { - // } else { - // return _justAudio!.duration; - // } } Future get position async { return _mkPlayer.state.position; - // if (mkSupportedPlatform) { - // } else { - // return _justAudio!.position; - // } } Future get bufferedPosition async { - if (mkSupportedPlatform) { - // audioplayers doesn't have the capability to get buffered position - return null; - } else { - return null; - } + return _mkPlayer.state.buffer; } Future get selectedDevice async { @@ -100,86 +82,39 @@ abstract class AudioPlayerInterface { bool get hasSource { return _mkPlayer.state.playlist.medias.isNotEmpty; - // if (mkSupportedPlatform) { - // return _mkPlayer.state.playlist.medias.isNotEmpty; - // } else { - // return _justAudio!.audioSource != null; - // } } // states bool get isPlaying { return _mkPlayer.state.playing; - // if (mkSupportedPlatform) { - // return _mkPlayer.state.playing; - // } else { - // return _justAudio!.playing; - // } } bool get isPaused { return !_mkPlayer.state.playing; - // if (mkSupportedPlatform) { - // return !_mkPlayer.state.playing; - // } else { - // return !isPlaying; - // } } bool get isStopped { return !hasSource; - // if (mkSupportedPlatform) { - // return !hasSource; - // } else { - // return _justAudio!.processingState == ja.ProcessingState.idle; - // } } Future get isCompleted async { return _mkPlayer.state.completed; - // if (mkSupportedPlatform) { - // return _mkPlayer.state.completed; - // } else { - // return _justAudio!.processingState == ja.ProcessingState.completed; - // } } Future get isShuffled async { return _mkPlayer.shuffled; - // if (mkSupportedPlatform) { - // return _mkPlayer.shuffled; - // } else { - // return _justAudio!.shuffleModeEnabled; - // } } PlaybackLoopMode get loopMode { return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.state.playlistMode); - // if (mkSupportedPlatform) { - // return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.loopMode); - // } else { - // return PlaybackLoopMode.fromLoopMode(_justAudio!.loopMode); - // } } /// Returns the current volume of the player, between 0 and 1 double get volume { return _mkPlayer.state.volume / 100; - // if (mkSupportedPlatform) { - // return _mkPlayer.state.volume / 100; - // } else { - // return _justAudio!.volume; - // } } bool get isBuffering { - return false; - // if (mkSupportedPlatform) { - // // audioplayers doesn't have the capability to get buffering state - // return false; - // } else { - // return _justAudio!.processingState == ja.ProcessingState.buffering || - // _justAudio!.processingState == ja.ProcessingState.loading; - // } + return _mkPlayer.state.buffering; } } From 2d1f4b9380c31853e0f00b05c595ad58ab826650 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 13 Apr 2024 13:12:20 +0600 Subject: [PATCH 049/261] chore: fix song link button not showing up --- lib/components/player/player.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 7d61aa85..49341058 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -26,6 +26,7 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; @@ -44,9 +45,10 @@ class PlayerView extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final auth = ref.watch(authenticationProvider); - final currentTrack = ref.watch(proxyPlaylistProvider.select( - (value) => value.activeTrack, - )); + final sourcedCurrentTrack = ref.watch(activeSourcedTrackProvider); + final currentActiveTrack = + ref.watch(proxyPlaylistProvider.select((s) => s.activeTrack)); + final currentTrack = sourcedCurrentTrack ?? currentActiveTrack; final isLocalTrack = currentTrack is LocalTrack; final mediaQuery = MediaQuery.of(context); @@ -150,7 +152,7 @@ class PlayerView extends HookConsumerWidget { label: Text(context.l10n.song_link), style: TextButton.styleFrom( foregroundColor: bodyTextColor, - padding: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 10), ), onPressed: () { final url = From 9791e3fb5f05d65096c8c6feb78462f72e13c0b5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 14 Apr 2024 12:02:12 +0600 Subject: [PATCH 050/261] chore: give a boost to first track of playlist --- lib/provider/proxy_playlist/proxy_playlist_provider.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 060ada1b..9811a1f8 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -7,12 +7,14 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -99,6 +101,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { state = state.copyWith(collections: {}); + // Giving the initial track a boost so MediaKit won't skip + // because of timeout + final intendedActiveTrack = tracks.elementAt(initialIndex); + if (intendedActiveTrack is! LocalTrack) { + await ref.read(sourcedTrackProvider(intendedActiveTrack).future); + } + await audioPlayer.openPlaylist( tracks.asMediaList(), initialIndex: initialIndex, From 9e25c742d4e43e4e10d2b48afb8e6d90288ffa11 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 14 Apr 2024 12:10:34 +0600 Subject: [PATCH 051/261] feat: add Spotify homepage personalized recommendations (#1402) * feat: add spotify homepage recommendations * chore: bring back made for user sectin --- lib/collections/assets.gen.dart | 2 +- lib/collections/fake.dart | 27 + lib/collections/routes.dart | 9 + lib/components/home/sections/feed.dart | 52 + .../horizontal_playbutton_card_view.dart | 25 +- lib/main.dart | 3 + lib/models/spotify/home_feed.dart | 247 +++ lib/models/spotify/home_feed.freezed.dart | 1666 +++++++++++++++++ lib/models/spotify/home_feed.g.dart | 169 ++ lib/pages/home/feed/feed_section.dart | 62 + lib/pages/home/home.dart | 2 + lib/pages/mobile_login/mobile_login.dart | 4 +- lib/provider/authentication_provider.dart | 18 +- lib/provider/spotify/views/home.dart | 22 + lib/provider/spotify/views/home_section.dart | 26 + .../spotify_endpoints.dart | 124 ++ pubspec.lock | 10 +- pubspec.yaml | 2 + 18 files changed, 2455 insertions(+), 15 deletions(-) create mode 100644 lib/components/home/sections/feed.dart create mode 100644 lib/models/spotify/home_feed.dart create mode 100644 lib/models/spotify/home_feed.freezed.dart create mode 100644 lib/models/spotify/home_feed.g.dart create mode 100644 lib/pages/home/feed/feed_section.dart create mode 100644 lib/provider/spotify/views/home.dart create mode 100644 lib/provider/spotify/views/home_section.dart diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart index 8a2950fb..2a30260b 100644 --- a/lib/collections/assets.gen.dart +++ b/lib/collections/assets.gen.dart @@ -88,7 +88,7 @@ class Assets { AssetGenImage('assets/user-placeholder.png'); /// List of all assets - List get values => [ + static List get values => [ albumPlaceholder, bengaliPatternsBg, branding, diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index c5379ec6..4df19dfc 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -1,5 +1,6 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; abstract class FakeData { @@ -196,4 +197,30 @@ abstract class FakeData { ), ], ); + + static final feedSection = SpotifyHomeFeedSection( + typename: "HomeGenericSectionData", + uri: "spotify:section:lol", + title: "Dummy", + items: [ + for (int i = 0; i < 10; i++) + SpotifyHomeFeedSectionItem( + typename: "PlaylistResponseWrapper", + playlist: SpotifySectionPlaylist( + name: "Playlist $i", + description: "Really super important description $i", + format: "daily-mix", + images: [ + const SpotifySectionItemImage( + height: 1, + width: 1, + url: "https://dummyimage.com/100x100/cfcfcf/cfcfcf.jpg", + ), + ], + owner: "Spotify", + uri: "spotify:playlist:id", + ), + ) + ], + ); } diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index aeeb4837..080cbd8a 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -9,6 +9,7 @@ import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/connect/connect.dart'; import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/pages/getting_started/getting_started.dart'; +import 'package:spotube/pages/home/feed/feed_section.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'; @@ -76,6 +77,14 @@ final routerProvider = Provider((ref) { ), ), ), + GoRoute( + path: "feeds/:feedId", + pageBuilder: (context, state) => SpotubePage( + child: HomeFeedSectionPage( + sectionUri: state.pathParameters["feedId"] as String, + ), + ), + ) ], ), GoRoute( diff --git a/lib/components/home/sections/feed.dart b/lib/components/home/sections/feed.dart new file mode 100644 index 00000000..793cd2c3 --- /dev/null +++ b/lib/components/home/sections/feed.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/provider/spotify/views/home.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class HomePageFeedSection extends HookConsumerWidget { + const HomePageFeedSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final homeFeed = ref.watch(homeViewProvider); + final nonShortSections = homeFeed.asData?.value?.sections + .where((s) => s.typename == "HomeGenericSectionData") + .toList() ?? + []; + + return SliverList.builder( + itemCount: nonShortSections.length, + itemBuilder: (context, index) { + final section = nonShortSections[index]; + if (section.items.isEmpty) return const SizedBox.shrink(); + + return HorizontalPlaybuttonCardView( + items: [ + for (final item in section.items) + if (item.album != null) + item.album!.asAlbum + else if (item.artist != null) + item.artist!.asArtist + else if (item.playlist != null) + item.playlist!.asPlaylist + ], + title: Text(section.title ?? "No Titel"), + hasNextPage: false, + isLoadingNextPage: false, + onFetchMore: () {}, + titleTrailing: Directionality( + textDirection: TextDirection.rtl, + child: TextButton.icon( + label: const Text("Browse More"), + icon: const Icon(SpotubeIcons.angleRight), + onPressed: () => + ServiceUtils.push(context, "/feeds/${section.uri}"), + ), + ), + ); + }, + ); + } +} diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index 8f0e6048..e142cb35 100644 --- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -17,18 +17,21 @@ class HorizontalPlaybuttonCardView extends HookWidget { final VoidCallback onFetchMore; final bool isLoadingNextPage; final bool hasNextPage; + final Widget? titleTrailing; - const HorizontalPlaybuttonCardView({ + HorizontalPlaybuttonCardView({ required this.title, required this.items, required this.hasNextPage, required this.onFetchMore, required this.isLoadingNextPage, + this.titleTrailing, super.key, }) : assert( - items is List || - items is List || - items is List, + items.every( + (item) => + item is PlaylistSimple || item is Artist || item is AlbumSimple, + ), ); @override @@ -48,9 +51,15 @@ class HorizontalPlaybuttonCardView extends HookWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - DefaultTextStyle( - style: textTheme.titleMedium!, - child: title, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + DefaultTextStyle( + style: textTheme.titleMedium!, + child: title, + ), + if (titleTrailing != null) titleTrailing!, + ], ), SizedBox( height: height, @@ -87,7 +96,7 @@ class HorizontalPlaybuttonCardView extends HookWidget { return switch (item) { PlaylistSimple() => PlaylistCard(item as PlaylistSimple), - Album() => AlbumCard(item as Album), + AlbumSimple() => AlbumCard(item as Album), Artist() => Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0), diff --git a/lib/main.dart b/lib/main.dart index 95724c79..0bb72932 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -39,6 +39,7 @@ import 'package:spotube/hooks/configurators/use_init_sys_tray.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; +import 'package:timezone/data/latest.dart' as tz; Future main(List rawArgs) async { final arguments = await startCLI(rawArgs); @@ -47,6 +48,8 @@ Future main(List rawArgs) async { await registerWindowsScheme("spotify"); + tz.initializeTimeZones(); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); MediaKit.ensureInitialized(); diff --git a/lib/models/spotify/home_feed.dart b/lib/models/spotify/home_feed.dart new file mode 100644 index 00000000..e5c2f666 --- /dev/null +++ b/lib/models/spotify/home_feed.dart @@ -0,0 +1,247 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; + +part 'home_feed.freezed.dart'; +part 'home_feed.g.dart'; + +@freezed +class SpotifySectionPlaylist with _$SpotifySectionPlaylist { + const SpotifySectionPlaylist._(); + + const factory SpotifySectionPlaylist({ + required String description, + required String format, + required List images, + required String name, + required String owner, + required String uri, + }) = _SpotifySectionPlaylist; + + factory SpotifySectionPlaylist.fromJson(Map json) => + _$SpotifySectionPlaylistFromJson(json); + + String get id => uri.split(":").last; + + Playlist get asPlaylist { + return Playlist() + ..id = id + ..name = name + ..description = description + ..collaborative = false + ..images = images.map((e) => e.asImage).toList() + ..owner = (User()..displayName = "Spotify") + ..uri = uri + ..type = "playlist"; + } +} + +@freezed +class SpotifySectionArtist with _$SpotifySectionArtist { + const SpotifySectionArtist._(); + + const factory SpotifySectionArtist({ + required String name, + required String uri, + required List images, + }) = _SpotifySectionArtist; + + factory SpotifySectionArtist.fromJson(Map json) => + _$SpotifySectionArtistFromJson(json); + + String get id => uri.split(":").last; + + Artist get asArtist { + return Artist() + ..id = id + ..name = name + ..images = images.map((e) => e.asImage).toList() + ..type = "artist" + ..uri = uri; + } +} + +@freezed +class SpotifySectionAlbum with _$SpotifySectionAlbum { + const SpotifySectionAlbum._(); + + const factory SpotifySectionAlbum({ + required List artists, + required List images, + required String name, + required String uri, + }) = _SpotifySectionAlbum; + + factory SpotifySectionAlbum.fromJson(Map json) => + _$SpotifySectionAlbumFromJson(json); + + String get id => uri.split(":").last; + + Album get asAlbum { + return Album() + ..id = id + ..name = name + ..artists = artists.map((a) => a.asArtist).toList() + ..albumType = AlbumType.album + ..images = images.map((e) => e.asImage).toList() + ..uri = uri; + } +} + +@freezed +class SpotifySectionAlbumArtist with _$SpotifySectionAlbumArtist { + const SpotifySectionAlbumArtist._(); + + const factory SpotifySectionAlbumArtist({ + required String name, + required String uri, + }) = _SpotifySectionAlbumArtist; + + factory SpotifySectionAlbumArtist.fromJson(Map json) => + _$SpotifySectionAlbumArtistFromJson(json); + + String get id => uri.split(":").last; + + Artist get asArtist { + return Artist() + ..id = id + ..name = name + ..type = "artist" + ..uri = uri; + } +} + +@freezed +class SpotifySectionItemImage with _$SpotifySectionItemImage { + const SpotifySectionItemImage._(); + + const factory SpotifySectionItemImage({ + required num? height, + required String url, + required num? width, + }) = _SpotifySectionItemImage; + + factory SpotifySectionItemImage.fromJson(Map json) => + _$SpotifySectionItemImageFromJson(json); + + Image get asImage { + return Image() + ..height = height?.toInt() + ..width = width?.toInt() + ..url = url; + } +} + +@freezed +class SpotifyHomeFeedSectionItem with _$SpotifyHomeFeedSectionItem { + factory SpotifyHomeFeedSectionItem({ + required String typename, + SpotifySectionPlaylist? playlist, + SpotifySectionArtist? artist, + SpotifySectionAlbum? album, + }) = _SpotifyHomeFeedSectionItem; + + factory SpotifyHomeFeedSectionItem.fromJson(Map json) => + _$SpotifyHomeFeedSectionItemFromJson(json); +} + +@freezed +class SpotifyHomeFeedSection with _$SpotifyHomeFeedSection { + factory SpotifyHomeFeedSection({ + required String typename, + String? title, + required String uri, + required List items, + }) = _SpotifyHomeFeedSection; + + factory SpotifyHomeFeedSection.fromJson(Map json) => + _$SpotifyHomeFeedSectionFromJson(json); +} + +@freezed +class SpotifyHomeFeed with _$SpotifyHomeFeed { + factory SpotifyHomeFeed({ + required String greeting, + required List sections, + }) = _SpotifyHomeFeed; + + factory SpotifyHomeFeed.fromJson(Map json) => + _$SpotifyHomeFeedFromJson(json); +} + +Map transformSectionItemTypeJsonMap( + Map json) { + final data = json["content"]["data"]; + final objType = json["content"]["data"]["__typename"]; + return { + "typename": json["content"]["__typename"], + if (objType == "Playlist") + "playlist": { + "name": data["name"], + "description": data["description"], + "format": data["format"], + "images": (data["images"]["items"] as List) + .expand((j) => j["sources"] as dynamic) + .toList() + .cast>(), + "owner": data["ownerV2"]["data"]["name"], + "uri": data["uri"] + }, + if (objType == "Artist") + "artist": { + "name": data["profile"]["name"], + "uri": data["uri"], + "images": data["visuals"]["avatarImage"]["sources"], + }, + if (objType == "Album") + "album": { + "name": data["name"], + "uri": data["uri"], + "images": data["coverArt"]["sources"], + "artists": data["artists"]["items"] + .map( + (artist) => { + "name": artist["profile"]["name"], + "uri": artist["uri"], + }, + ) + .toList() + }, + }; +} + +Map transformSectionItemJsonMap(Map json) { + return { + "typename": json["data"]["__typename"], + "title": json["data"]?["title"]?["text"], + "uri": json["uri"], + "items": (json["sectionItems"]["items"] as List) + .map( + (data) => + transformSectionItemTypeJsonMap(data as Map) + as dynamic, + ) + .where( + (w) => + w["playlist"] != null || + w["artist"] != null || + w["album"] != null, + ) + .toList() + .cast>() + }; +} + +Map transformHomeFeedJsonMap(Map json) { + return { + "greeting": json["data"]["home"]["greeting"]["text"], + "sections": + (json["data"]["home"]["sectionContainer"]["sections"]["items"] as List) + .map( + (item) => + transformSectionItemJsonMap(item as Map) + as dynamic, + ) + .toList() + .cast>() + }; +} diff --git a/lib/models/spotify/home_feed.freezed.dart b/lib/models/spotify/home_feed.freezed.dart new file mode 100644 index 00000000..97c4ffc7 --- /dev/null +++ b/lib/models/spotify/home_feed.freezed.dart @@ -0,0 +1,1666 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'home_feed.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +SpotifySectionPlaylist _$SpotifySectionPlaylistFromJson( + Map json) { + return _SpotifySectionPlaylist.fromJson(json); +} + +/// @nodoc +mixin _$SpotifySectionPlaylist { + String get description => throw _privateConstructorUsedError; + String get format => throw _privateConstructorUsedError; + List get images => + throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get owner => throw _privateConstructorUsedError; + String get uri => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SpotifySectionPlaylistCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifySectionPlaylistCopyWith<$Res> { + factory $SpotifySectionPlaylistCopyWith(SpotifySectionPlaylist value, + $Res Function(SpotifySectionPlaylist) then) = + _$SpotifySectionPlaylistCopyWithImpl<$Res, SpotifySectionPlaylist>; + @useResult + $Res call( + {String description, + String format, + List images, + String name, + String owner, + String uri}); +} + +/// @nodoc +class _$SpotifySectionPlaylistCopyWithImpl<$Res, + $Val extends SpotifySectionPlaylist> + implements $SpotifySectionPlaylistCopyWith<$Res> { + _$SpotifySectionPlaylistCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? description = null, + Object? format = null, + Object? images = null, + Object? name = null, + Object? owner = null, + Object? uri = null, + }) { + return _then(_value.copyWith( + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + format: null == format + ? _value.format + : format // ignore: cast_nullable_to_non_nullable + as String, + images: null == images + ? _value.images + : images // ignore: cast_nullable_to_non_nullable + as List, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + owner: null == owner + ? _value.owner + : owner // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifySectionPlaylistImplCopyWith<$Res> + implements $SpotifySectionPlaylistCopyWith<$Res> { + factory _$$SpotifySectionPlaylistImplCopyWith( + _$SpotifySectionPlaylistImpl value, + $Res Function(_$SpotifySectionPlaylistImpl) then) = + __$$SpotifySectionPlaylistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String description, + String format, + List images, + String name, + String owner, + String uri}); +} + +/// @nodoc +class __$$SpotifySectionPlaylistImplCopyWithImpl<$Res> + extends _$SpotifySectionPlaylistCopyWithImpl<$Res, + _$SpotifySectionPlaylistImpl> + implements _$$SpotifySectionPlaylistImplCopyWith<$Res> { + __$$SpotifySectionPlaylistImplCopyWithImpl( + _$SpotifySectionPlaylistImpl _value, + $Res Function(_$SpotifySectionPlaylistImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? description = null, + Object? format = null, + Object? images = null, + Object? name = null, + Object? owner = null, + Object? uri = null, + }) { + return _then(_$SpotifySectionPlaylistImpl( + description: null == description + ? _value.description + : description // ignore: cast_nullable_to_non_nullable + as String, + format: null == format + ? _value.format + : format // ignore: cast_nullable_to_non_nullable + as String, + images: null == images + ? _value._images + : images // ignore: cast_nullable_to_non_nullable + as List, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + owner: null == owner + ? _value.owner + : owner // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifySectionPlaylistImpl extends _SpotifySectionPlaylist { + const _$SpotifySectionPlaylistImpl( + {required this.description, + required this.format, + required final List images, + required this.name, + required this.owner, + required this.uri}) + : _images = images, + super._(); + + factory _$SpotifySectionPlaylistImpl.fromJson(Map json) => + _$$SpotifySectionPlaylistImplFromJson(json); + + @override + final String description; + @override + final String format; + final List _images; + @override + List get images { + if (_images is EqualUnmodifiableListView) return _images; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_images); + } + + @override + final String name; + @override + final String owner; + @override + final String uri; + + @override + String toString() { + return 'SpotifySectionPlaylist(description: $description, format: $format, images: $images, name: $name, owner: $owner, uri: $uri)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifySectionPlaylistImpl && + (identical(other.description, description) || + other.description == description) && + (identical(other.format, format) || other.format == format) && + const DeepCollectionEquality().equals(other._images, _images) && + (identical(other.name, name) || other.name == name) && + (identical(other.owner, owner) || other.owner == owner) && + (identical(other.uri, uri) || other.uri == uri)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, description, format, + const DeepCollectionEquality().hash(_images), name, owner, uri); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpotifySectionPlaylistImplCopyWith<_$SpotifySectionPlaylistImpl> + get copyWith => __$$SpotifySectionPlaylistImplCopyWithImpl< + _$SpotifySectionPlaylistImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SpotifySectionPlaylistImplToJson( + this, + ); + } +} + +abstract class _SpotifySectionPlaylist extends SpotifySectionPlaylist { + const factory _SpotifySectionPlaylist( + {required final String description, + required final String format, + required final List images, + required final String name, + required final String owner, + required final String uri}) = _$SpotifySectionPlaylistImpl; + const _SpotifySectionPlaylist._() : super._(); + + factory _SpotifySectionPlaylist.fromJson(Map json) = + _$SpotifySectionPlaylistImpl.fromJson; + + @override + String get description; + @override + String get format; + @override + List get images; + @override + String get name; + @override + String get owner; + @override + String get uri; + @override + @JsonKey(ignore: true) + _$$SpotifySectionPlaylistImplCopyWith<_$SpotifySectionPlaylistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifySectionArtist _$SpotifySectionArtistFromJson(Map json) { + return _SpotifySectionArtist.fromJson(json); +} + +/// @nodoc +mixin _$SpotifySectionArtist { + String get name => throw _privateConstructorUsedError; + String get uri => throw _privateConstructorUsedError; + List get images => + throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SpotifySectionArtistCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifySectionArtistCopyWith<$Res> { + factory $SpotifySectionArtistCopyWith(SpotifySectionArtist value, + $Res Function(SpotifySectionArtist) then) = + _$SpotifySectionArtistCopyWithImpl<$Res, SpotifySectionArtist>; + @useResult + $Res call({String name, String uri, List images}); +} + +/// @nodoc +class _$SpotifySectionArtistCopyWithImpl<$Res, + $Val extends SpotifySectionArtist> + implements $SpotifySectionArtistCopyWith<$Res> { + _$SpotifySectionArtistCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? uri = null, + Object? images = null, + }) { + return _then(_value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + images: null == images + ? _value.images + : images // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifySectionArtistImplCopyWith<$Res> + implements $SpotifySectionArtistCopyWith<$Res> { + factory _$$SpotifySectionArtistImplCopyWith(_$SpotifySectionArtistImpl value, + $Res Function(_$SpotifySectionArtistImpl) then) = + __$$SpotifySectionArtistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String name, String uri, List images}); +} + +/// @nodoc +class __$$SpotifySectionArtistImplCopyWithImpl<$Res> + extends _$SpotifySectionArtistCopyWithImpl<$Res, _$SpotifySectionArtistImpl> + implements _$$SpotifySectionArtistImplCopyWith<$Res> { + __$$SpotifySectionArtistImplCopyWithImpl(_$SpotifySectionArtistImpl _value, + $Res Function(_$SpotifySectionArtistImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? uri = null, + Object? images = null, + }) { + return _then(_$SpotifySectionArtistImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + images: null == images + ? _value._images + : images // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifySectionArtistImpl extends _SpotifySectionArtist { + const _$SpotifySectionArtistImpl( + {required this.name, + required this.uri, + required final List images}) + : _images = images, + super._(); + + factory _$SpotifySectionArtistImpl.fromJson(Map json) => + _$$SpotifySectionArtistImplFromJson(json); + + @override + final String name; + @override + final String uri; + final List _images; + @override + List get images { + if (_images is EqualUnmodifiableListView) return _images; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_images); + } + + @override + String toString() { + return 'SpotifySectionArtist(name: $name, uri: $uri, images: $images)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifySectionArtistImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.uri, uri) || other.uri == uri) && + const DeepCollectionEquality().equals(other._images, _images)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, name, uri, const DeepCollectionEquality().hash(_images)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpotifySectionArtistImplCopyWith<_$SpotifySectionArtistImpl> + get copyWith => + __$$SpotifySectionArtistImplCopyWithImpl<_$SpotifySectionArtistImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$SpotifySectionArtistImplToJson( + this, + ); + } +} + +abstract class _SpotifySectionArtist extends SpotifySectionArtist { + const factory _SpotifySectionArtist( + {required final String name, + required final String uri, + required final List images}) = + _$SpotifySectionArtistImpl; + const _SpotifySectionArtist._() : super._(); + + factory _SpotifySectionArtist.fromJson(Map json) = + _$SpotifySectionArtistImpl.fromJson; + + @override + String get name; + @override + String get uri; + @override + List get images; + @override + @JsonKey(ignore: true) + _$$SpotifySectionArtistImplCopyWith<_$SpotifySectionArtistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifySectionAlbum _$SpotifySectionAlbumFromJson(Map json) { + return _SpotifySectionAlbum.fromJson(json); +} + +/// @nodoc +mixin _$SpotifySectionAlbum { + List get artists => + throw _privateConstructorUsedError; + List get images => + throw _privateConstructorUsedError; + String get name => throw _privateConstructorUsedError; + String get uri => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SpotifySectionAlbumCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifySectionAlbumCopyWith<$Res> { + factory $SpotifySectionAlbumCopyWith( + SpotifySectionAlbum value, $Res Function(SpotifySectionAlbum) then) = + _$SpotifySectionAlbumCopyWithImpl<$Res, SpotifySectionAlbum>; + @useResult + $Res call( + {List artists, + List images, + String name, + String uri}); +} + +/// @nodoc +class _$SpotifySectionAlbumCopyWithImpl<$Res, $Val extends SpotifySectionAlbum> + implements $SpotifySectionAlbumCopyWith<$Res> { + _$SpotifySectionAlbumCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? artists = null, + Object? images = null, + Object? name = null, + Object? uri = null, + }) { + return _then(_value.copyWith( + artists: null == artists + ? _value.artists + : artists // ignore: cast_nullable_to_non_nullable + as List, + images: null == images + ? _value.images + : images // ignore: cast_nullable_to_non_nullable + as List, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifySectionAlbumImplCopyWith<$Res> + implements $SpotifySectionAlbumCopyWith<$Res> { + factory _$$SpotifySectionAlbumImplCopyWith(_$SpotifySectionAlbumImpl value, + $Res Function(_$SpotifySectionAlbumImpl) then) = + __$$SpotifySectionAlbumImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {List artists, + List images, + String name, + String uri}); +} + +/// @nodoc +class __$$SpotifySectionAlbumImplCopyWithImpl<$Res> + extends _$SpotifySectionAlbumCopyWithImpl<$Res, _$SpotifySectionAlbumImpl> + implements _$$SpotifySectionAlbumImplCopyWith<$Res> { + __$$SpotifySectionAlbumImplCopyWithImpl(_$SpotifySectionAlbumImpl _value, + $Res Function(_$SpotifySectionAlbumImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? artists = null, + Object? images = null, + Object? name = null, + Object? uri = null, + }) { + return _then(_$SpotifySectionAlbumImpl( + artists: null == artists + ? _value._artists + : artists // ignore: cast_nullable_to_non_nullable + as List, + images: null == images + ? _value._images + : images // ignore: cast_nullable_to_non_nullable + as List, + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifySectionAlbumImpl extends _SpotifySectionAlbum { + const _$SpotifySectionAlbumImpl( + {required final List artists, + required final List images, + required this.name, + required this.uri}) + : _artists = artists, + _images = images, + super._(); + + factory _$SpotifySectionAlbumImpl.fromJson(Map json) => + _$$SpotifySectionAlbumImplFromJson(json); + + final List _artists; + @override + List get artists { + if (_artists is EqualUnmodifiableListView) return _artists; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_artists); + } + + final List _images; + @override + List get images { + if (_images is EqualUnmodifiableListView) return _images; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_images); + } + + @override + final String name; + @override + final String uri; + + @override + String toString() { + return 'SpotifySectionAlbum(artists: $artists, images: $images, name: $name, uri: $uri)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifySectionAlbumImpl && + const DeepCollectionEquality().equals(other._artists, _artists) && + const DeepCollectionEquality().equals(other._images, _images) && + (identical(other.name, name) || other.name == name) && + (identical(other.uri, uri) || other.uri == uri)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_artists), + const DeepCollectionEquality().hash(_images), + name, + uri); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpotifySectionAlbumImplCopyWith<_$SpotifySectionAlbumImpl> get copyWith => + __$$SpotifySectionAlbumImplCopyWithImpl<_$SpotifySectionAlbumImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$SpotifySectionAlbumImplToJson( + this, + ); + } +} + +abstract class _SpotifySectionAlbum extends SpotifySectionAlbum { + const factory _SpotifySectionAlbum( + {required final List artists, + required final List images, + required final String name, + required final String uri}) = _$SpotifySectionAlbumImpl; + const _SpotifySectionAlbum._() : super._(); + + factory _SpotifySectionAlbum.fromJson(Map json) = + _$SpotifySectionAlbumImpl.fromJson; + + @override + List get artists; + @override + List get images; + @override + String get name; + @override + String get uri; + @override + @JsonKey(ignore: true) + _$$SpotifySectionAlbumImplCopyWith<_$SpotifySectionAlbumImpl> get copyWith => + throw _privateConstructorUsedError; +} + +SpotifySectionAlbumArtist _$SpotifySectionAlbumArtistFromJson( + Map json) { + return _SpotifySectionAlbumArtist.fromJson(json); +} + +/// @nodoc +mixin _$SpotifySectionAlbumArtist { + String get name => throw _privateConstructorUsedError; + String get uri => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SpotifySectionAlbumArtistCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifySectionAlbumArtistCopyWith<$Res> { + factory $SpotifySectionAlbumArtistCopyWith(SpotifySectionAlbumArtist value, + $Res Function(SpotifySectionAlbumArtist) then) = + _$SpotifySectionAlbumArtistCopyWithImpl<$Res, SpotifySectionAlbumArtist>; + @useResult + $Res call({String name, String uri}); +} + +/// @nodoc +class _$SpotifySectionAlbumArtistCopyWithImpl<$Res, + $Val extends SpotifySectionAlbumArtist> + implements $SpotifySectionAlbumArtistCopyWith<$Res> { + _$SpotifySectionAlbumArtistCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? uri = null, + }) { + return _then(_value.copyWith( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifySectionAlbumArtistImplCopyWith<$Res> + implements $SpotifySectionAlbumArtistCopyWith<$Res> { + factory _$$SpotifySectionAlbumArtistImplCopyWith( + _$SpotifySectionAlbumArtistImpl value, + $Res Function(_$SpotifySectionAlbumArtistImpl) then) = + __$$SpotifySectionAlbumArtistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String name, String uri}); +} + +/// @nodoc +class __$$SpotifySectionAlbumArtistImplCopyWithImpl<$Res> + extends _$SpotifySectionAlbumArtistCopyWithImpl<$Res, + _$SpotifySectionAlbumArtistImpl> + implements _$$SpotifySectionAlbumArtistImplCopyWith<$Res> { + __$$SpotifySectionAlbumArtistImplCopyWithImpl( + _$SpotifySectionAlbumArtistImpl _value, + $Res Function(_$SpotifySectionAlbumArtistImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? name = null, + Object? uri = null, + }) { + return _then(_$SpotifySectionAlbumArtistImpl( + name: null == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifySectionAlbumArtistImpl extends _SpotifySectionAlbumArtist { + const _$SpotifySectionAlbumArtistImpl({required this.name, required this.uri}) + : super._(); + + factory _$SpotifySectionAlbumArtistImpl.fromJson(Map json) => + _$$SpotifySectionAlbumArtistImplFromJson(json); + + @override + final String name; + @override + final String uri; + + @override + String toString() { + return 'SpotifySectionAlbumArtist(name: $name, uri: $uri)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifySectionAlbumArtistImpl && + (identical(other.name, name) || other.name == name) && + (identical(other.uri, uri) || other.uri == uri)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, name, uri); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpotifySectionAlbumArtistImplCopyWith<_$SpotifySectionAlbumArtistImpl> + get copyWith => __$$SpotifySectionAlbumArtistImplCopyWithImpl< + _$SpotifySectionAlbumArtistImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SpotifySectionAlbumArtistImplToJson( + this, + ); + } +} + +abstract class _SpotifySectionAlbumArtist extends SpotifySectionAlbumArtist { + const factory _SpotifySectionAlbumArtist( + {required final String name, + required final String uri}) = _$SpotifySectionAlbumArtistImpl; + const _SpotifySectionAlbumArtist._() : super._(); + + factory _SpotifySectionAlbumArtist.fromJson(Map json) = + _$SpotifySectionAlbumArtistImpl.fromJson; + + @override + String get name; + @override + String get uri; + @override + @JsonKey(ignore: true) + _$$SpotifySectionAlbumArtistImplCopyWith<_$SpotifySectionAlbumArtistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifySectionItemImage _$SpotifySectionItemImageFromJson( + Map json) { + return _SpotifySectionItemImage.fromJson(json); +} + +/// @nodoc +mixin _$SpotifySectionItemImage { + num? get height => throw _privateConstructorUsedError; + String get url => throw _privateConstructorUsedError; + num? get width => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SpotifySectionItemImageCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifySectionItemImageCopyWith<$Res> { + factory $SpotifySectionItemImageCopyWith(SpotifySectionItemImage value, + $Res Function(SpotifySectionItemImage) then) = + _$SpotifySectionItemImageCopyWithImpl<$Res, SpotifySectionItemImage>; + @useResult + $Res call({num? height, String url, num? width}); +} + +/// @nodoc +class _$SpotifySectionItemImageCopyWithImpl<$Res, + $Val extends SpotifySectionItemImage> + implements $SpotifySectionItemImageCopyWith<$Res> { + _$SpotifySectionItemImageCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? height = freezed, + Object? url = null, + Object? width = freezed, + }) { + return _then(_value.copyWith( + height: freezed == height + ? _value.height + : height // ignore: cast_nullable_to_non_nullable + as num?, + url: null == url + ? _value.url + : url // ignore: cast_nullable_to_non_nullable + as String, + width: freezed == width + ? _value.width + : width // ignore: cast_nullable_to_non_nullable + as num?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifySectionItemImageImplCopyWith<$Res> + implements $SpotifySectionItemImageCopyWith<$Res> { + factory _$$SpotifySectionItemImageImplCopyWith( + _$SpotifySectionItemImageImpl value, + $Res Function(_$SpotifySectionItemImageImpl) then) = + __$$SpotifySectionItemImageImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({num? height, String url, num? width}); +} + +/// @nodoc +class __$$SpotifySectionItemImageImplCopyWithImpl<$Res> + extends _$SpotifySectionItemImageCopyWithImpl<$Res, + _$SpotifySectionItemImageImpl> + implements _$$SpotifySectionItemImageImplCopyWith<$Res> { + __$$SpotifySectionItemImageImplCopyWithImpl( + _$SpotifySectionItemImageImpl _value, + $Res Function(_$SpotifySectionItemImageImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? height = freezed, + Object? url = null, + Object? width = freezed, + }) { + return _then(_$SpotifySectionItemImageImpl( + height: freezed == height + ? _value.height + : height // ignore: cast_nullable_to_non_nullable + as num?, + url: null == url + ? _value.url + : url // ignore: cast_nullable_to_non_nullable + as String, + width: freezed == width + ? _value.width + : width // ignore: cast_nullable_to_non_nullable + as num?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifySectionItemImageImpl extends _SpotifySectionItemImage { + const _$SpotifySectionItemImageImpl( + {required this.height, required this.url, required this.width}) + : super._(); + + factory _$SpotifySectionItemImageImpl.fromJson(Map json) => + _$$SpotifySectionItemImageImplFromJson(json); + + @override + final num? height; + @override + final String url; + @override + final num? width; + + @override + String toString() { + return 'SpotifySectionItemImage(height: $height, url: $url, width: $width)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifySectionItemImageImpl && + (identical(other.height, height) || other.height == height) && + (identical(other.url, url) || other.url == url) && + (identical(other.width, width) || other.width == width)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, height, url, width); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpotifySectionItemImageImplCopyWith<_$SpotifySectionItemImageImpl> + get copyWith => __$$SpotifySectionItemImageImplCopyWithImpl< + _$SpotifySectionItemImageImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SpotifySectionItemImageImplToJson( + this, + ); + } +} + +abstract class _SpotifySectionItemImage extends SpotifySectionItemImage { + const factory _SpotifySectionItemImage( + {required final num? height, + required final String url, + required final num? width}) = _$SpotifySectionItemImageImpl; + const _SpotifySectionItemImage._() : super._(); + + factory _SpotifySectionItemImage.fromJson(Map json) = + _$SpotifySectionItemImageImpl.fromJson; + + @override + num? get height; + @override + String get url; + @override + num? get width; + @override + @JsonKey(ignore: true) + _$$SpotifySectionItemImageImplCopyWith<_$SpotifySectionItemImageImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifyHomeFeedSectionItem _$SpotifyHomeFeedSectionItemFromJson( + Map json) { + return _SpotifyHomeFeedSectionItem.fromJson(json); +} + +/// @nodoc +mixin _$SpotifyHomeFeedSectionItem { + String get typename => throw _privateConstructorUsedError; + SpotifySectionPlaylist? get playlist => throw _privateConstructorUsedError; + SpotifySectionArtist? get artist => throw _privateConstructorUsedError; + SpotifySectionAlbum? get album => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SpotifyHomeFeedSectionItemCopyWith + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifyHomeFeedSectionItemCopyWith<$Res> { + factory $SpotifyHomeFeedSectionItemCopyWith(SpotifyHomeFeedSectionItem value, + $Res Function(SpotifyHomeFeedSectionItem) then) = + _$SpotifyHomeFeedSectionItemCopyWithImpl<$Res, + SpotifyHomeFeedSectionItem>; + @useResult + $Res call( + {String typename, + SpotifySectionPlaylist? playlist, + SpotifySectionArtist? artist, + SpotifySectionAlbum? album}); + + $SpotifySectionPlaylistCopyWith<$Res>? get playlist; + $SpotifySectionArtistCopyWith<$Res>? get artist; + $SpotifySectionAlbumCopyWith<$Res>? get album; +} + +/// @nodoc +class _$SpotifyHomeFeedSectionItemCopyWithImpl<$Res, + $Val extends SpotifyHomeFeedSectionItem> + implements $SpotifyHomeFeedSectionItemCopyWith<$Res> { + _$SpotifyHomeFeedSectionItemCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? typename = null, + Object? playlist = freezed, + Object? artist = freezed, + Object? album = freezed, + }) { + return _then(_value.copyWith( + typename: null == typename + ? _value.typename + : typename // ignore: cast_nullable_to_non_nullable + as String, + playlist: freezed == playlist + ? _value.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as SpotifySectionPlaylist?, + artist: freezed == artist + ? _value.artist + : artist // ignore: cast_nullable_to_non_nullable + as SpotifySectionArtist?, + album: freezed == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as SpotifySectionAlbum?, + ) as $Val); + } + + @override + @pragma('vm:prefer-inline') + $SpotifySectionPlaylistCopyWith<$Res>? get playlist { + if (_value.playlist == null) { + return null; + } + + return $SpotifySectionPlaylistCopyWith<$Res>(_value.playlist!, (value) { + return _then(_value.copyWith(playlist: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $SpotifySectionArtistCopyWith<$Res>? get artist { + if (_value.artist == null) { + return null; + } + + return $SpotifySectionArtistCopyWith<$Res>(_value.artist!, (value) { + return _then(_value.copyWith(artist: value) as $Val); + }); + } + + @override + @pragma('vm:prefer-inline') + $SpotifySectionAlbumCopyWith<$Res>? get album { + if (_value.album == null) { + return null; + } + + return $SpotifySectionAlbumCopyWith<$Res>(_value.album!, (value) { + return _then(_value.copyWith(album: value) as $Val); + }); + } +} + +/// @nodoc +abstract class _$$SpotifyHomeFeedSectionItemImplCopyWith<$Res> + implements $SpotifyHomeFeedSectionItemCopyWith<$Res> { + factory _$$SpotifyHomeFeedSectionItemImplCopyWith( + _$SpotifyHomeFeedSectionItemImpl value, + $Res Function(_$SpotifyHomeFeedSectionItemImpl) then) = + __$$SpotifyHomeFeedSectionItemImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String typename, + SpotifySectionPlaylist? playlist, + SpotifySectionArtist? artist, + SpotifySectionAlbum? album}); + + @override + $SpotifySectionPlaylistCopyWith<$Res>? get playlist; + @override + $SpotifySectionArtistCopyWith<$Res>? get artist; + @override + $SpotifySectionAlbumCopyWith<$Res>? get album; +} + +/// @nodoc +class __$$SpotifyHomeFeedSectionItemImplCopyWithImpl<$Res> + extends _$SpotifyHomeFeedSectionItemCopyWithImpl<$Res, + _$SpotifyHomeFeedSectionItemImpl> + implements _$$SpotifyHomeFeedSectionItemImplCopyWith<$Res> { + __$$SpotifyHomeFeedSectionItemImplCopyWithImpl( + _$SpotifyHomeFeedSectionItemImpl _value, + $Res Function(_$SpotifyHomeFeedSectionItemImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? typename = null, + Object? playlist = freezed, + Object? artist = freezed, + Object? album = freezed, + }) { + return _then(_$SpotifyHomeFeedSectionItemImpl( + typename: null == typename + ? _value.typename + : typename // ignore: cast_nullable_to_non_nullable + as String, + playlist: freezed == playlist + ? _value.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as SpotifySectionPlaylist?, + artist: freezed == artist + ? _value.artist + : artist // ignore: cast_nullable_to_non_nullable + as SpotifySectionArtist?, + album: freezed == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as SpotifySectionAlbum?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifyHomeFeedSectionItemImpl implements _SpotifyHomeFeedSectionItem { + _$SpotifyHomeFeedSectionItemImpl( + {required this.typename, this.playlist, this.artist, this.album}); + + factory _$SpotifyHomeFeedSectionItemImpl.fromJson( + Map json) => + _$$SpotifyHomeFeedSectionItemImplFromJson(json); + + @override + final String typename; + @override + final SpotifySectionPlaylist? playlist; + @override + final SpotifySectionArtist? artist; + @override + final SpotifySectionAlbum? album; + + @override + String toString() { + return 'SpotifyHomeFeedSectionItem(typename: $typename, playlist: $playlist, artist: $artist, album: $album)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifyHomeFeedSectionItemImpl && + (identical(other.typename, typename) || + other.typename == typename) && + (identical(other.playlist, playlist) || + other.playlist == playlist) && + (identical(other.artist, artist) || other.artist == artist) && + (identical(other.album, album) || other.album == album)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => + Object.hash(runtimeType, typename, playlist, artist, album); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpotifyHomeFeedSectionItemImplCopyWith<_$SpotifyHomeFeedSectionItemImpl> + get copyWith => __$$SpotifyHomeFeedSectionItemImplCopyWithImpl< + _$SpotifyHomeFeedSectionItemImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SpotifyHomeFeedSectionItemImplToJson( + this, + ); + } +} + +abstract class _SpotifyHomeFeedSectionItem + implements SpotifyHomeFeedSectionItem { + factory _SpotifyHomeFeedSectionItem( + {required final String typename, + final SpotifySectionPlaylist? playlist, + final SpotifySectionArtist? artist, + final SpotifySectionAlbum? album}) = _$SpotifyHomeFeedSectionItemImpl; + + factory _SpotifyHomeFeedSectionItem.fromJson(Map json) = + _$SpotifyHomeFeedSectionItemImpl.fromJson; + + @override + String get typename; + @override + SpotifySectionPlaylist? get playlist; + @override + SpotifySectionArtist? get artist; + @override + SpotifySectionAlbum? get album; + @override + @JsonKey(ignore: true) + _$$SpotifyHomeFeedSectionItemImplCopyWith<_$SpotifyHomeFeedSectionItemImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifyHomeFeedSection _$SpotifyHomeFeedSectionFromJson( + Map json) { + return _SpotifyHomeFeedSection.fromJson(json); +} + +/// @nodoc +mixin _$SpotifyHomeFeedSection { + String get typename => throw _privateConstructorUsedError; + String? get title => throw _privateConstructorUsedError; + String get uri => throw _privateConstructorUsedError; + List get items => + throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SpotifyHomeFeedSectionCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifyHomeFeedSectionCopyWith<$Res> { + factory $SpotifyHomeFeedSectionCopyWith(SpotifyHomeFeedSection value, + $Res Function(SpotifyHomeFeedSection) then) = + _$SpotifyHomeFeedSectionCopyWithImpl<$Res, SpotifyHomeFeedSection>; + @useResult + $Res call( + {String typename, + String? title, + String uri, + List items}); +} + +/// @nodoc +class _$SpotifyHomeFeedSectionCopyWithImpl<$Res, + $Val extends SpotifyHomeFeedSection> + implements $SpotifyHomeFeedSectionCopyWith<$Res> { + _$SpotifyHomeFeedSectionCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? typename = null, + Object? title = freezed, + Object? uri = null, + Object? items = null, + }) { + return _then(_value.copyWith( + typename: null == typename + ? _value.typename + : typename // ignore: cast_nullable_to_non_nullable + as String, + title: freezed == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + items: null == items + ? _value.items + : items // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifyHomeFeedSectionImplCopyWith<$Res> + implements $SpotifyHomeFeedSectionCopyWith<$Res> { + factory _$$SpotifyHomeFeedSectionImplCopyWith( + _$SpotifyHomeFeedSectionImpl value, + $Res Function(_$SpotifyHomeFeedSectionImpl) then) = + __$$SpotifyHomeFeedSectionImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String typename, + String? title, + String uri, + List items}); +} + +/// @nodoc +class __$$SpotifyHomeFeedSectionImplCopyWithImpl<$Res> + extends _$SpotifyHomeFeedSectionCopyWithImpl<$Res, + _$SpotifyHomeFeedSectionImpl> + implements _$$SpotifyHomeFeedSectionImplCopyWith<$Res> { + __$$SpotifyHomeFeedSectionImplCopyWithImpl( + _$SpotifyHomeFeedSectionImpl _value, + $Res Function(_$SpotifyHomeFeedSectionImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? typename = null, + Object? title = freezed, + Object? uri = null, + Object? items = null, + }) { + return _then(_$SpotifyHomeFeedSectionImpl( + typename: null == typename + ? _value.typename + : typename // ignore: cast_nullable_to_non_nullable + as String, + title: freezed == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String?, + uri: null == uri + ? _value.uri + : uri // ignore: cast_nullable_to_non_nullable + as String, + items: null == items + ? _value._items + : items // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifyHomeFeedSectionImpl implements _SpotifyHomeFeedSection { + _$SpotifyHomeFeedSectionImpl( + {required this.typename, + this.title, + required this.uri, + required final List items}) + : _items = items; + + factory _$SpotifyHomeFeedSectionImpl.fromJson(Map json) => + _$$SpotifyHomeFeedSectionImplFromJson(json); + + @override + final String typename; + @override + final String? title; + @override + final String uri; + final List _items; + @override + List get items { + if (_items is EqualUnmodifiableListView) return _items; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_items); + } + + @override + String toString() { + return 'SpotifyHomeFeedSection(typename: $typename, title: $title, uri: $uri, items: $items)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifyHomeFeedSectionImpl && + (identical(other.typename, typename) || + other.typename == typename) && + (identical(other.title, title) || other.title == title) && + (identical(other.uri, uri) || other.uri == uri) && + const DeepCollectionEquality().equals(other._items, _items)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, typename, title, uri, + const DeepCollectionEquality().hash(_items)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpotifyHomeFeedSectionImplCopyWith<_$SpotifyHomeFeedSectionImpl> + get copyWith => __$$SpotifyHomeFeedSectionImplCopyWithImpl< + _$SpotifyHomeFeedSectionImpl>(this, _$identity); + + @override + Map toJson() { + return _$$SpotifyHomeFeedSectionImplToJson( + this, + ); + } +} + +abstract class _SpotifyHomeFeedSection implements SpotifyHomeFeedSection { + factory _SpotifyHomeFeedSection( + {required final String typename, + final String? title, + required final String uri, + required final List items}) = + _$SpotifyHomeFeedSectionImpl; + + factory _SpotifyHomeFeedSection.fromJson(Map json) = + _$SpotifyHomeFeedSectionImpl.fromJson; + + @override + String get typename; + @override + String? get title; + @override + String get uri; + @override + List get items; + @override + @JsonKey(ignore: true) + _$$SpotifyHomeFeedSectionImplCopyWith<_$SpotifyHomeFeedSectionImpl> + get copyWith => throw _privateConstructorUsedError; +} + +SpotifyHomeFeed _$SpotifyHomeFeedFromJson(Map json) { + return _SpotifyHomeFeed.fromJson(json); +} + +/// @nodoc +mixin _$SpotifyHomeFeed { + String get greeting => throw _privateConstructorUsedError; + List get sections => + throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SpotifyHomeFeedCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SpotifyHomeFeedCopyWith<$Res> { + factory $SpotifyHomeFeedCopyWith( + SpotifyHomeFeed value, $Res Function(SpotifyHomeFeed) then) = + _$SpotifyHomeFeedCopyWithImpl<$Res, SpotifyHomeFeed>; + @useResult + $Res call({String greeting, List sections}); +} + +/// @nodoc +class _$SpotifyHomeFeedCopyWithImpl<$Res, $Val extends SpotifyHomeFeed> + implements $SpotifyHomeFeedCopyWith<$Res> { + _$SpotifyHomeFeedCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? greeting = null, + Object? sections = null, + }) { + return _then(_value.copyWith( + greeting: null == greeting + ? _value.greeting + : greeting // ignore: cast_nullable_to_non_nullable + as String, + sections: null == sections + ? _value.sections + : sections // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$SpotifyHomeFeedImplCopyWith<$Res> + implements $SpotifyHomeFeedCopyWith<$Res> { + factory _$$SpotifyHomeFeedImplCopyWith(_$SpotifyHomeFeedImpl value, + $Res Function(_$SpotifyHomeFeedImpl) then) = + __$$SpotifyHomeFeedImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String greeting, List sections}); +} + +/// @nodoc +class __$$SpotifyHomeFeedImplCopyWithImpl<$Res> + extends _$SpotifyHomeFeedCopyWithImpl<$Res, _$SpotifyHomeFeedImpl> + implements _$$SpotifyHomeFeedImplCopyWith<$Res> { + __$$SpotifyHomeFeedImplCopyWithImpl( + _$SpotifyHomeFeedImpl _value, $Res Function(_$SpotifyHomeFeedImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? greeting = null, + Object? sections = null, + }) { + return _then(_$SpotifyHomeFeedImpl( + greeting: null == greeting + ? _value.greeting + : greeting // ignore: cast_nullable_to_non_nullable + as String, + sections: null == sections + ? _value._sections + : sections // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$SpotifyHomeFeedImpl implements _SpotifyHomeFeed { + _$SpotifyHomeFeedImpl( + {required this.greeting, + required final List sections}) + : _sections = sections; + + factory _$SpotifyHomeFeedImpl.fromJson(Map json) => + _$$SpotifyHomeFeedImplFromJson(json); + + @override + final String greeting; + final List _sections; + @override + List get sections { + if (_sections is EqualUnmodifiableListView) return _sections; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_sections); + } + + @override + String toString() { + return 'SpotifyHomeFeed(greeting: $greeting, sections: $sections)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SpotifyHomeFeedImpl && + (identical(other.greeting, greeting) || + other.greeting == greeting) && + const DeepCollectionEquality().equals(other._sections, _sections)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash( + runtimeType, greeting, const DeepCollectionEquality().hash(_sections)); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$SpotifyHomeFeedImplCopyWith<_$SpotifyHomeFeedImpl> get copyWith => + __$$SpotifyHomeFeedImplCopyWithImpl<_$SpotifyHomeFeedImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$SpotifyHomeFeedImplToJson( + this, + ); + } +} + +abstract class _SpotifyHomeFeed implements SpotifyHomeFeed { + factory _SpotifyHomeFeed( + {required final String greeting, + required final List sections}) = + _$SpotifyHomeFeedImpl; + + factory _SpotifyHomeFeed.fromJson(Map json) = + _$SpotifyHomeFeedImpl.fromJson; + + @override + String get greeting; + @override + List get sections; + @override + @JsonKey(ignore: true) + _$$SpotifyHomeFeedImplCopyWith<_$SpotifyHomeFeedImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/models/spotify/home_feed.g.dart b/lib/models/spotify/home_feed.g.dart new file mode 100644 index 00000000..73a4f909 --- /dev/null +++ b/lib/models/spotify/home_feed.g.dart @@ -0,0 +1,169 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'home_feed.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson( + Map json) => + _$SpotifySectionPlaylistImpl( + description: json['description'] as String, + format: json['format'] as String, + images: (json['images'] as List) + .map((e) => + SpotifySectionItemImage.fromJson(e as Map)) + .toList(), + name: json['name'] as String, + owner: json['owner'] as String, + uri: json['uri'] as String, + ); + +Map _$$SpotifySectionPlaylistImplToJson( + _$SpotifySectionPlaylistImpl instance) => + { + 'description': instance.description, + 'format': instance.format, + 'images': instance.images, + 'name': instance.name, + 'owner': instance.owner, + 'uri': instance.uri, + }; + +_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson( + Map json) => + _$SpotifySectionArtistImpl( + name: json['name'] as String, + uri: json['uri'] as String, + images: (json['images'] as List) + .map((e) => + SpotifySectionItemImage.fromJson(e as Map)) + .toList(), + ); + +Map _$$SpotifySectionArtistImplToJson( + _$SpotifySectionArtistImpl instance) => + { + 'name': instance.name, + 'uri': instance.uri, + 'images': instance.images, + }; + +_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson( + Map json) => + _$SpotifySectionAlbumImpl( + artists: (json['artists'] as List) + .map((e) => + SpotifySectionAlbumArtist.fromJson(e as Map)) + .toList(), + images: (json['images'] as List) + .map((e) => + SpotifySectionItemImage.fromJson(e as Map)) + .toList(), + name: json['name'] as String, + uri: json['uri'] as String, + ); + +Map _$$SpotifySectionAlbumImplToJson( + _$SpotifySectionAlbumImpl instance) => + { + 'artists': instance.artists, + 'images': instance.images, + 'name': instance.name, + 'uri': instance.uri, + }; + +_$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson( + Map json) => + _$SpotifySectionAlbumArtistImpl( + name: json['name'] as String, + uri: json['uri'] as String, + ); + +Map _$$SpotifySectionAlbumArtistImplToJson( + _$SpotifySectionAlbumArtistImpl instance) => + { + 'name': instance.name, + 'uri': instance.uri, + }; + +_$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson( + Map json) => + _$SpotifySectionItemImageImpl( + height: json['height'] as num?, + url: json['url'] as String, + width: json['width'] as num?, + ); + +Map _$$SpotifySectionItemImageImplToJson( + _$SpotifySectionItemImageImpl instance) => + { + 'height': instance.height, + 'url': instance.url, + 'width': instance.width, + }; + +_$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson( + Map json) => + _$SpotifyHomeFeedSectionItemImpl( + typename: json['typename'] as String, + playlist: json['playlist'] == null + ? null + : SpotifySectionPlaylist.fromJson( + json['playlist'] as Map), + artist: json['artist'] == null + ? null + : SpotifySectionArtist.fromJson( + json['artist'] as Map), + album: json['album'] == null + ? null + : SpotifySectionAlbum.fromJson(json['album'] as Map), + ); + +Map _$$SpotifyHomeFeedSectionItemImplToJson( + _$SpotifyHomeFeedSectionItemImpl instance) => + { + 'typename': instance.typename, + 'playlist': instance.playlist, + 'artist': instance.artist, + 'album': instance.album, + }; + +_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson( + Map json) => + _$SpotifyHomeFeedSectionImpl( + typename: json['typename'] as String, + title: json['title'] as String?, + uri: json['uri'] as String, + items: (json['items'] as List) + .map((e) => + SpotifyHomeFeedSectionItem.fromJson(e as Map)) + .toList(), + ); + +Map _$$SpotifyHomeFeedSectionImplToJson( + _$SpotifyHomeFeedSectionImpl instance) => + { + 'typename': instance.typename, + 'title': instance.title, + 'uri': instance.uri, + 'items': instance.items, + }; + +_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson( + Map json) => + _$SpotifyHomeFeedImpl( + greeting: json['greeting'] as String, + sections: (json['sections'] as List) + .map( + (e) => SpotifyHomeFeedSection.fromJson(e as Map)) + .toList(), + ); + +Map _$$SpotifyHomeFeedImplToJson( + _$SpotifyHomeFeedImpl instance) => + { + 'greeting': instance.greeting, + 'sections': instance.sections, + }; diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart new file mode 100644 index 00000000..40ac2482 --- /dev/null +++ b/lib/pages/home/feed/feed_section.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.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/page_window_title_bar.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/provider/spotify/views/home_section.dart'; + +class HomeFeedSectionPage extends HookConsumerWidget { + final String sectionUri; + const HomeFeedSectionPage({super.key, required this.sectionUri}); + + @override + Widget build(BuildContext context, ref) { + final homeFeedSection = ref.watch(homeSectionViewProvider(sectionUri)); + final section = homeFeedSection.asData?.value ?? FakeData.feedSection; + + return Skeletonizer( + enabled: homeFeedSection.isLoading, + child: Scaffold( + appBar: PageWindowTitleBar( + title: Text(section.title ?? ""), + centerTitle: false, + automaticallyImplyLeading: true, + titleSpacing: 0, + ), + body: CustomScrollView( + slivers: [ + SliverLayoutBuilder( + builder: (context, constrains) { + return SliverGrid.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.smAndDown ? 225 : 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: section.items.length, + itemBuilder: (context, index) { + final item = section.items[index]; + + if (item.album != null) { + return AlbumCard(item.album!.asAlbum); + } else if (item.artist != null) { + return ArtistCard(item.artist!.asArtist); + } else if (item.playlist != null) { + return PlaylistCard(item.playlist!.asPlaylist); + } + return const SizedBox(); + }, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 5b959621..e37898a8 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -5,6 +5,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; +import 'package:spotube/components/home/sections/feed.dart'; import 'package:spotube/components/home/sections/friends.dart'; import 'package:spotube/components/home/sections/genres.dart'; import 'package:spotube/components/home/sections/made_for_user.dart'; @@ -66,6 +67,7 @@ class HomePage extends HookConsumerWidget { const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), const SliverToBoxAdapter(child: HomeNewReleasesSection()), + const HomePageFeedSection(), const SliverSafeArea(sliver: HomeMadeForUserSection()), ], ), diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 1afca919..0a1ff8b3 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -12,7 +11,6 @@ class WebViewLogin extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final mounted = useIsMounted(); final authenticationNotifier = ref.watch(authenticationProvider.notifier); if (kIsDesktop) { @@ -57,7 +55,7 @@ class WebViewLogin extends HookConsumerWidget { authenticationNotifier.setCredentials( await AuthenticationCredentials.fromCookie(cookieHeader), ); - if (mounted()) { + if (context.mounted) { // ignore: use_build_context_synchronously GoRouter.of(context).go("/"); } diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index f7549ad7..a82f82c0 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:collection/collection.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart'; @@ -25,12 +26,16 @@ class AuthenticationCredentials { static Future fromCookie(String cookie) async { try { + final spDc = cookie + .split("; ") + .firstWhereOrNull((c) => c.trim().startsWith("sp_dc=")) + ?.trim(); final res = await get( Uri.parse( "https://open.spotify.com/get_access_token?reason=transport&productType=web_player", ), headers: { - "Cookie": cookie, + "Cookie": spDc ?? "", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" }, @@ -44,7 +49,7 @@ class AuthenticationCredentials { } return AuthenticationCredentials( - cookie: cookie, + cookie: "${res.headers["set-cookie"]}; $spDc", accessToken: body['accessToken'], expiration: DateTime.fromMillisecondsSinceEpoch( body['accessTokenExpirationTimestampMs'], @@ -64,6 +69,15 @@ class AuthenticationCredentials { } } + /// Returns the cookie value + String? getCookie(String key) => cookie + .split("; ") + .firstWhereOrNull((c) => c.trim().startsWith("$key=")) + ?.trim() + .split("=") + .last + .replaceAll(";", ""); + factory AuthenticationCredentials.fromJson(Map json) { return AuthenticationCredentials( cookie: json['cookie'] as String, diff --git a/lib/provider/spotify/views/home.dart b/lib/provider/spotify/views/home.dart new file mode 100644 index 00000000..810d110d --- /dev/null +++ b/lib/provider/spotify/views/home.dart @@ -0,0 +1,22 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +final homeViewProvider = FutureProvider((ref) async { + final country = ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + final spTCookie = ref.watch( + authenticationProvider.select((s) => s?.getCookie("sp_t")), + ); + + if (spTCookie == null) return null; + + final spotify = ref.watch(customSpotifyEndpointProvider); + + return spotify.getHomeFeed( + country: country, + spTCookie: spTCookie, + ); +}); diff --git a/lib/provider/spotify/views/home_section.dart b/lib/provider/spotify/views/home_section.dart new file mode 100644 index 00000000..1078fa72 --- /dev/null +++ b/lib/provider/spotify/views/home_section.dart @@ -0,0 +1,26 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/models/spotify/home_feed.dart'; +import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +final homeSectionViewProvider = + FutureProvider.family( + (ref, sectionUri) async { + final country = ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + final spTCookie = ref.watch( + authenticationProvider.select((s) => s?.getCookie("sp_t")), + ); + + if (spTCookie == null) return null; + + final spotify = ref.watch(customSpotifyEndpointProvider); + + return spotify.getHomeFeedSection( + sectionUri, + country: country, + spTCookie: spTCookie, + ); +}); diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index d1c078a7..d8600366 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -2,7 +2,9 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:spotify/spotify.dart'; +import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; +import 'package:timezone/timezone.dart' as tz; class CustomSpotifyEndpoints { static const _baseUrl = 'https://api.spotify.com/v1'; @@ -175,4 +177,126 @@ class CustomSpotifyEndpoints { ); return SpotifyFriends.fromJson(jsonDecode(res.body)); } + + Future getHomeFeed({ + required String spTCookie, + required Market country, + }) async { + final headers = { + 'app-platform': 'WebPlayer', + 'authorization': 'Bearer $accessToken', + 'content-type': 'application/json;charset=UTF-8', + 'dnt': '1', + 'origin': 'https://open.spotify.com', + 'referer': 'https://open.spotify.com/' + }; + final response = await http.get( + Uri( + scheme: "https", + host: "api-partner.spotify.com", + path: "/pathfinder/v1/query", + queryParameters: { + "operationName": "home", + "variables": jsonEncode({ + "timeZone": tz.local.name, + "sp_t": spTCookie, + "country": country.name, + "facet": null, + "sectionItemsLimit": 10 + }), + "extensions": jsonEncode( + { + "persistedQuery": { + "version": 1, + + /// GraphQL persisted Query hash + /// This can change overtime. We've to lookout for it + /// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/ + "sha256Hash": + "eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be", + } + }, + ), + }, + ), + headers: headers, + ); + + if (response.statusCode >= 400) { + throw Exception( + "[RequestException] " + "Status: ${response.statusCode}\n" + "Body: ${response.body}", + ); + } + + final data = SpotifyHomeFeed.fromJson( + transformHomeFeedJsonMap( + jsonDecode(response.body), + ), + ); + + return data; + } + + Future getHomeFeedSection( + String sectionUri, { + required String spTCookie, + required Market country, + }) async { + final headers = { + 'app-platform': 'WebPlayer', + 'authorization': 'Bearer $accessToken', + 'content-type': 'application/json;charset=UTF-8', + 'dnt': '1', + 'origin': 'https://open.spotify.com', + 'referer': 'https://open.spotify.com/' + }; + final response = await http.get( + Uri( + scheme: "https", + host: "api-partner.spotify.com", + path: "/pathfinder/v1/query", + queryParameters: { + "operationName": "homeSection", + "variables": jsonEncode({ + "timeZone": tz.local.name, + "sp_t": spTCookie, + "country": country.name, + "uri": sectionUri + }), + "extensions": jsonEncode( + { + "persistedQuery": { + "version": 1, + + /// GraphQL persisted Query hash + /// This can change overtime. We've to lookout for it + /// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/ + "sha256Hash": + "eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be", + } + }, + ), + }, + ), + headers: headers, + ); + + if (response.statusCode >= 400) { + throw Exception( + "[RequestException] " + "Status: ${response.statusCode}\n" + "Body: ${response.body}", + ); + } + + final data = SpotifyHomeFeedSection.fromJson( + transformSectionItemJsonMap( + jsonDecode(response.body)["data"]["homeSections"]["sections"][0], + ), + ); + + return data; + } } diff --git a/pubspec.lock b/pubspec.lock index bc1f962f..8d19f604 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -434,7 +434,7 @@ packages: source: hosted version: "0.3.3+5" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab @@ -2238,6 +2238,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + timezone: + dependency: "direct main" + description: + name: timezone + sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" + url: "https://pub.dev" + source: hosted + version: "0.9.2" timing: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dfd77387..16f51981 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -131,6 +131,8 @@ dependencies: lrc: ^1.0.2 pub_api_client: ^2.4.0 pubspec_parse: ^1.2.2 + timezone: ^0.9.2 + crypto: ^3.0.3 dev_dependencies: build_runner: ^2.4.9 From 6e07fec1a50281f0cbd2def10357eeea4414a627 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 15 Apr 2024 18:01:35 +0600 Subject: [PATCH 052/261] chore: fix no window button and feed section page bottom overflow --- lib/pages/home/feed/feed_section.dart | 5 +++++ lib/pages/home/home.dart | 1 + 2 files changed, 6 insertions(+) diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart index 40ac2482..c945251c 100644 --- a/lib/pages/home/feed/feed_section.dart +++ b/lib/pages/home/feed/feed_section.dart @@ -54,6 +54,11 @@ class HomeFeedSectionPage extends HookConsumerWidget { ); }, ), + const SliverToBoxAdapter( + child: SafeArea( + child: SizedBox(), + ), + ), ], ), ), diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index e37898a8..d5639274 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -29,6 +29,7 @@ class HomePage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( + appBar: kIsMobile || kIsMacOS ? null : const PageWindowTitleBar(), body: CustomScrollView( controller: controller, slivers: [ From 6f4c30845783f436c447229f9886cdddfbf63717 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 15 Apr 2024 19:11:59 +0600 Subject: [PATCH 053/261] chore: fix shuffle doesn't move active track to top and library gridview with floating filter field --- lib/components/connect/connect_device.dart | 86 +++++------ lib/components/library/user_albums.dart | 120 ++++++++-------- lib/components/library/user_artists.dart | 136 +++++++++--------- lib/components/library/user_local_tracks.dart | 4 +- lib/components/library/user_playlists.dart | 60 ++++---- lib/pages/connect/connect.dart | 1 + lib/pages/home/genres/genres.dart | 1 + lib/pages/home/home.dart | 7 +- lib/services/audio_player/custom_player.dart | 4 + 9 files changed, 212 insertions(+), 207 deletions(-) diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart index 14243fa8..3ac585df 100644 --- a/lib/components/connect/connect_device.dart +++ b/lib/components/connect/connect_device.dart @@ -52,52 +52,58 @@ class ConnectDeviceButton extends HookConsumerWidget { alignment: Alignment.centerRight, fit: StackFit.loose, children: [ - Center( - child: InkWell( - onTap: () { - ServiceUtils.push(context, "/connect"); - }, - borderRadius: BorderRadius.circular(50), - child: Ink( - decoration: BoxDecoration( + Material( + type: MaterialType.transparency, + child: Center( + child: ClipRect( + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () { + ServiceUtils.push(context, "/connect"); + }, borderRadius: BorderRadius.circular(50), - color: colorScheme.primaryContainer, - ), - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (connectClients.asData?.value.resolvedService != - null) ...[ - Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: Colors.greenAccent, - borderRadius: BorderRadius.circular(50), - ), - ), - const Gap(5), - ], - Text(context.l10n.devices), - if (connectClients.asData?.value.services.isNotEmpty == - true) - Text( - " (${connectClients.asData?.value.services.length})", - style: TextStyle( - color: - colorScheme.onPrimaryContainer.withOpacity(0.5), - ), - ), - const Gap(35), - ], + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50), + color: colorScheme.primaryContainer, + ), + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (connectClients.asData?.value.resolvedService != + null) ...[ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: Colors.greenAccent, + borderRadius: BorderRadius.circular(50), + ), + ), + const Gap(5), + ], + Text(context.l10n.devices), + if (connectClients.asData?.value.services.isNotEmpty == + true) + Text( + " (${connectClients.asData?.value.services.length})", + style: TextStyle( + color: colorScheme.onPrimaryContainer + .withOpacity(0.5), + ), + ), + const Gap(35), + ], + ), + ), ), ), ), ), Positioned( - right: 0, + right: -3, child: IconButton.filled( icon: const Icon(SpotubeIcons.speaker), style: IconButton.styleFrom( diff --git a/lib/components/library/user_albums.dart b/lib/components/library/user_albums.dart index 43fa0165..e1b82113 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/components/library/user_albums.dart @@ -2,17 +2,17 @@ import 'package:flutter/material.dart' hide Image; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:collection/collection.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: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/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/waypoint.dart'; -import 'package:spotube/extensions/album_simple.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -50,71 +50,65 @@ class UserAlbums extends HookConsumerWidget { return const AnonymousFallback(); } - final theme = Theme.of(context); - - return RefreshIndicator( - onRefresh: () async { - ref.invalidate(favoriteAlbumsProvider); - }, - child: SafeArea( - child: Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(50), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: ColoredBox( - color: theme.scaffoldBackgroundColor, - child: SearchBar( - onChanged: (value) => searchText.value = value, - leading: const Icon(SpotubeIcons.filter), - hintText: context.l10n.filter_albums, - ), - ), - ), - ), - body: SizedBox.expand( - child: InterScrollbar( + return SafeArea( + child: Scaffold( + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(favoriteAlbumsProvider); + }, + child: InterScrollbar( + controller: controller, + child: CustomScrollView( controller: controller, - child: SingleChildScrollView( - padding: const EdgeInsets.all(8.0), - controller: controller, - child: Skeletonizer( - enabled: albumsQuery.isLoading, - child: Center( - child: Wrap( - runSpacing: 20, - alignment: WrapAlignment.center, - runAlignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - if (albumsQuery.asData?.value == null || - albumsQuery.asData!.value.items.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(album.toAlbum()), - if (albums.isNotEmpty && - albumsQuery.asData?.value.hasMore == true) - Skeletonizer( - enabled: true, - child: Waypoint( - controller: controller, - isGrid: true, - onTouchEdge: albumsQueryNotifier.fetchMore, - child: AlbumCard(FakeData.album), - ), - ) - ], + slivers: [ + SliverAppBar( + floating: true, + flexibleSpace: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: SearchBar( + onChanged: (value) => searchText.value = value, + leading: const Icon(SpotubeIcons.filter), + hintText: context.l10n.filter_albums, ), ), ), - ), + const SliverGap(10), + Skeletonizer.sliver( + enabled: albumsQuery.isLoading, + child: SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid.builder( + itemCount: albums.isEmpty ? 6 : albums.length + 1, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.smAndDown ? 225 : 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + if (albums.isNotEmpty && index == albums.length) { + if (albumsQuery.asData?.value.hasMore != true) { + return const SizedBox.shrink(); + } + + return Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: albumsQueryNotifier.fetchMore, + child: Skeletonizer( + enabled: true, + child: AlbumCard(FakeData.albumSimple), + ), + ); + } + + return AlbumCard( + albums.elementAtOrNull(index) ?? FakeData.albumSimple, + ); + }, + ); + }), + ), + ], ), ), ), diff --git a/lib/components/library/user_artists.dart b/lib/components/library/user_artists.dart index 83db35c6..0ef0ff39 100644 --- a/lib/components/library/user_artists.dart +++ b/lib/components/library/user_artists.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:collection/collection.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:spotube/collections/fake.dart'; @@ -9,8 +10,9 @@ 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/components/shared/waypoint.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -20,10 +22,10 @@ class UserArtists extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final theme = Theme.of(context); final auth = ref.watch(authenticationProvider); final artistQuery = ref.watch(followedArtistsProvider); + final artistQueryNotifier = ref.watch(followedArtistsProvider.notifier); final searchText = useState(''); @@ -50,77 +52,73 @@ class UserArtists extends HookConsumerWidget { return const AnonymousFallback(); } - return Scaffold( - appBar: PreferredSize( - preferredSize: const Size.fromHeight(50), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: ColoredBox( - color: theme.scaffoldBackgroundColor, - child: SearchBar( - onChanged: (value) => searchText.value = value, - leading: const Icon(SpotubeIcons.filter), - hintText: context.l10n.filter_artist, + return SafeArea( + child: Scaffold( + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(followedArtistsProvider); + }, + child: InterScrollbar( + controller: controller, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: CustomScrollView( + controller: controller, + slivers: [ + SliverAppBar( + floating: true, + flexibleSpace: SearchBar( + onChanged: (value) => searchText.value = value, + leading: const Icon(SpotubeIcons.filter), + hintText: context.l10n.filter_artist, + ), + ), + const SliverGap(10), + Skeletonizer.sliver( + enabled: artistQuery.isLoading, + child: SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid.builder( + itemCount: filteredArtists.isEmpty + ? 6 + : filteredArtists.length + 1, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.smAndDown ? 225 : 250, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemBuilder: (context, index) { + if (filteredArtists.isNotEmpty && + index == filteredArtists.length) { + if (artistQuery.asData?.value.hasMore != true) { + return const SizedBox.shrink(); + } + + return Waypoint( + controller: controller, + isGrid: true, + onTouchEdge: artistQueryNotifier.fetchMore, + child: Skeletonizer( + enabled: true, + child: ArtistCard(FakeData.artist), + ), + ); + } + + return ArtistCard( + filteredArtists.elementAtOrNull(index) ?? + FakeData.artist, + ); + }, + ); + }), + ), + ], + ), ), ), ), ), - backgroundColor: theme.scaffoldBackgroundColor, - body: artistQuery.asData?.value.items.isEmpty == true - ? Padding( - padding: const EdgeInsets.all(20), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const CircularProgressIndicator(), - const SizedBox(width: 10), - Text(context.l10n.loading), - ], - ), - ) - : RefreshIndicator( - onRefresh: () async { - ref.invalidate(followedArtistsProvider); - }, - child: InterScrollbar( - controller: controller, - child: SingleChildScrollView( - controller: controller, - child: SizedBox( - width: double.infinity, - child: SafeArea( - child: Center( - 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(), - ), - ), - ), - ), - ), - ), - ), - ), ); } } diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index f8bd1326..a7b2102b 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -176,7 +176,7 @@ class UserLocalTracks extends HookConsumerWidget { padding: const EdgeInsets.all(8.0), child: Row( children: [ - const SizedBox(width: 10), + const SizedBox(width: 5), FilledButton( onPressed: trackSnapshot.asData?.value != null ? () async { @@ -212,7 +212,7 @@ class UserLocalTracks extends HookConsumerWidget { sortBy.value = value; }, ), - const SizedBox(width: 10), + const SizedBox(width: 5), FilledButton( child: const Icon(SpotubeIcons.refresh), onPressed: () { diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 563541de..069dfad9 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide Image; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:collection/collection.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'; @@ -18,6 +19,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/utils/platform.dart'; class UserPlaylists extends HookConsumerWidget { const UserPlaylists({super.key}); @@ -86,39 +88,37 @@ class UserPlaylists extends HookConsumerWidget { child: CustomScrollView( controller: controller, slivers: [ - SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(10), - child: SearchBar( - onChanged: (value) => searchText.value = value, - hintText: context.l10n.filter_playlists, - leading: const Icon(SpotubeIcons.filter), + SliverAppBar( + floating: true, + flexibleSpace: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SearchBar( + onChanged: (value) => searchText.value = value, + hintText: context.l10n.filter_playlists, + leading: const Icon(SpotubeIcons.filter), + ), + ), + bottom: PreferredSize( + preferredSize: + Size.fromHeight(kIsDesktop ? 35 : kToolbarHeight), + child: Row( + children: [ + const Gap(10), + const PlaylistCreateDialogButton(), + const Gap(10), + ElevatedButton.icon( + icon: const Icon(SpotubeIcons.magic), + label: Text(context.l10n.generate_playlist), + onPressed: () { + GoRouter.of(context).push("/library/generate"); + }, ), - ), - Row( - children: [ - const SizedBox(width: 10), - const PlaylistCreateDialogButton(), - const SizedBox(width: 10), - ElevatedButton.icon( - icon: const Icon(SpotubeIcons.magic), - label: Text(context.l10n.generate_playlist), - onPressed: () { - GoRouter.of(context).push("/library/generate"); - }, - ), - const SizedBox(width: 10), - ], - ), - ], + const Gap(10), + ], + ), ), ), - const SliverToBoxAdapter( - child: SizedBox(height: 10), - ), + const SliverGap(10), SliverLayoutBuilder(builder: (context, constrains) { return SliverGrid.builder( itemCount: playlists.isEmpty ? 6 : playlists.length + 1, diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index 170a0c72..cbdb446e 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -23,6 +23,7 @@ class ConnectPage extends HookConsumerWidget { appBar: PageWindowTitleBar( automaticallyImplyLeading: true, title: Text(context.l10n.devices), + titleSpacing: 0, ), body: ListTileTheme( shape: RoundedRectangleBorder( diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index a981cbe7..291ce737 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -26,6 +26,7 @@ class GenrePage extends HookConsumerWidget { appBar: PageWindowTitleBar( title: Text(context.l10n.explore_genres), automaticallyImplyLeading: true, + titleSpacing: 0, ), body: SafeArea( top: false, diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index d5639274..31f26bee 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/feed.dart'; @@ -34,8 +34,9 @@ class HomePage extends HookConsumerWidget { controller: controller, slivers: [ if (mediaQuery.mdAndDown) - PageWindowTitleBar.sliver( - pinned: DesktopTools.platform.isDesktop, + SliverAppBar( + floating: true, + title: Assets.spotubeLogoPng.image(height: 45), actions: [ const ConnectDeviceButton(), const Gap(10), diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart index d273519e..916a983f 100644 --- a/lib/services/audio_player/custom_player.dart +++ b/lib/services/audio_player/custom_player.dart @@ -106,6 +106,10 @@ class CustomPlayer extends Player { _shuffled = shuffle; await super.setShuffle(shuffle); _shuffleStream.add(shuffle); + await Future.delayed(const Duration(milliseconds: 100)); + if (shuffle) { + await move(state.playlist.index, 0); + } } @override From 5a6b80091259359bc38c4b91cd8cb496c4270fa4 Mon Sep 17 00:00:00 2001 From: Tutislav Date: Mon, 15 Apr 2024 15:26:19 +0200 Subject: [PATCH 054/261] feat(translations): Add Czech translation (#1401) --- lib/collections/language_codes.dart | 8 +- lib/l10n/app_cs.arb | 324 ++++++++++++++++++++++++++++ lib/l10n/l10n.dart | 2 + 3 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 lib/l10n/app_cs.arb diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index bd3f8740..45456d69 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -157,10 +157,10 @@ abstract class LanguageLocals { // name: "Croatian", // nativeName: "hrvatski", // ), - // "cs": const ISOLanguageName( - // name: "Czech", - // nativeName: "česky, čeština", - // ), + "cs": const ISOLanguageName( + name: "Czech", + nativeName: "česky, čeština", + ), // "da": const ISOLanguageName( // name: "Danish", // nativeName: "dansk", diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb new file mode 100644 index 00000000..52f5bcf8 --- /dev/null +++ b/lib/l10n/app_cs.arb @@ -0,0 +1,324 @@ +{ + "guest": "Host", + "browse": "Procházet", + "search": "Hledat", + "library": "Knihovna", + "lyrics": "Texty", + "settings": "Nastavení", + "genre_categories_filter": "Filtrovat kategorie nebo žánry...", + "genre": "Žánr", + "personalized": "Personalizované", + "featured": "Doporučené", + "new_releases": "Nově vydané", + "songs": "Skladby", + "playing_track": "Hraje {track}", + "queue_clear_alert": "Toto vymaže aktuální frontu. {track_length} skladeb bude odstraněno\nChcete pokračovat?", + "load_more": "Načíst více", + "playlists": "Playlisty", + "artists": "Umělci", + "albums": "Alba", + "tracks": "Skladby", + "downloads": "Stahování", + "filter_playlists": "Filtrovat playlisty...", + "liked_tracks": "Oblíbené skladby", + "liked_tracks_description": "Všechny vaše oblíbené skladby", + "create_playlist": "Vytvořit playlist", + "create_a_playlist": "Vytvořit playlist", + "update_playlist": "Aktualizovat playlist", + "create": "Vytvořit", + "cancel": "Zrušit", + "update": "Aktualizovat", + "playlist_name": "Název playlistu", + "name_of_playlist": "Název playlistu", + "description": "Popis", + "public": "Veřejné", + "collaborative": "Společný", + "search_local_tracks": "Hledat místní skladby...", + "play": "Přehrát", + "delete": "Smazat", + "none": "Žádné", + "sort_a_z": "Seřadit od A-Z", + "sort_z_a": "Seřadit od Z-A", + "sort_artist": "Seřadit podle umělce", + "sort_album": "Seřadit podle alba", + "sort_duration": "Seřadit podle délky", + "sort_tracks": "Seřadit skladby", + "currently_downloading": "Právě se stahuje ({tracks_length})", + "cancel_all": "Zrušit vše", + "filter_artist": "Filtrovat umělce...", + "followers": "{followers} Sledující", + "add_artist_to_blacklist": "Přidat umělce na černou listinu", + "top_tracks": "Top skladby", + "fans_also_like": "Fanoušci mají také rádi", + "loading": "Načítání...", + "artist": "Umělec", + "blacklisted": "Na černé listině", + "following": "Sleduje", + "follow": "Sledovat", + "artist_url_copied": "URL umělce zkopírována do schránky", + "added_to_queue": "Přidáno {tracks} skladeb do fronty", + "filter_albums": "Filtrovat alba...", + "synced": "Synchronizováno", + "plain": "Jednoduché", + "shuffle": "Zamíchat", + "search_tracks": "Hledat skladby...", + "released": "Vydáno", + "error": "Chyba {error}", + "title": "Název", + "time": "Čas", + "more_actions": "Více akcí", + "download_count": "Stáhnout ({count})", + "add_count_to_playlist": "Přidat ({count}) do playlistu", + "add_count_to_queue": "Přidat ({count}) do fronty", + "play_count_next": "Přehrát ({count}) dalších", + "album": "Album", + "copied_to_clipboard": "Zkopírováno {data} do schránky", + "add_to_following_playlists": "Přidat {track} do následujících playlistů", + "add": "Přidat", + "added_track_to_queue": "Přidána skladba {track} do fronty", + "add_to_queue": "Přidat do fronty", + "track_will_play_next": "{track} se přehraje jako další", + "play_next": "Přehrát další", + "removed_track_from_queue": "Odstraněna skladba {track} z fronty", + "remove_from_queue": "Odstranit z fronty", + "remove_from_favorites": "Odstranit z oblíbených", + "save_as_favorite": "Uložit jako oblíbené", + "add_to_playlist": "Přidat do playlistu", + "remove_from_playlist": "Odstranit z playlistu", + "add_to_blacklist": "Přidat na černou listinu", + "remove_from_blacklist": "Odstranit z černé listiny", + "share": "Sdílet", + "mini_player": "Mini přehrávač", + "slide_to_seek": "Táhněte pro posunutí vpřed nebo vzad", + "shuffle_playlist": "Zamíchat playlist", + "unshuffle_playlist": "Zrušit zamíchání playlistu", + "previous_track": "Předchozí skladba", + "next_track": "Další skladba", + "pause_playback": "Pozastavit přehrávání", + "resume_playback": "Pokračovat v přehrávání", + "loop_track": "Opakovat skladbu", + "repeat_playlist": "Opakovat playlist", + "queue": "Fronta", + "alternative_track_sources": "Alternativní zdroje skladeb", + "download_track": "Stáhnout skladbu", + "tracks_in_queue": "{tracks} skladeb ve frontě", + "clear_all": "Vymazat vše", + "show_hide_ui_on_hover": "Zobrazit/Skrýt UI při najetí", + "always_on_top": "Vždy nahoře", + "exit_mini_player": "Zavřít mini přehrávač", + "download_location": "Umístění stahování", + "account": "Účet", + "login_with_spotify": "Přihlásit se pomocí Spotify účtu", + "connect_with_spotify": "Připojit k Spotify", + "logout": "Odhlásit se", + "logout_of_this_account": "Odhlásit se z tohoto účtu", + "language_region": "Jazyk a region", + "language": "Jazyk", + "system_default": "Systém", + "market_place_region": "Region", + "recommendation_country": "Země pro doporučení", + "appearance": "Vzhled", + "layout_mode": "Režim rozložení", + "override_layout_settings": "Přepsat režim rozložení", + "adaptive": "Adaptivní", + "compact": "Kompaktní", + "extended": "Rozšířený", + "theme": "Téma", + "dark": "Tmavé", + "light": "Světlé", + "system": "Systém", + "accent_color": "Barva akcentu", + "sync_album_color": "Synchronizovat barvu alba", + "sync_album_color_description": "Používá dominantní barvu obalu alba jako barvu akcentu", + "playback": "Přehrávání", + "audio_quality": "Kvalita zvuku", + "high": "Vysoká", + "low": "Nízká", + "pre_download_play": "Předstáhnout a přehrát", + "pre_download_play_description": "Místo streamování audia stáhnout skladbu a přehrát (doporučeno pro uživatele s rychlejším internetem)", + "skip_non_music": "Přeskočit nehudební segmenty (SponsorBlock)", + "blacklist_description": "Zakázané skladby a umělci", + "wait_for_download_to_finish": "Počkejte, až se dokončí stahování", + "desktop": "Desktop", + "close_behavior": "Chování při zavření", + "close": "Zavřít", + "minimize_to_tray": "Minimalizovat do lišty", + "show_tray_icon": "Zobrazit ikonu v systémové liště", + "about": "O aplikaci", + "u_love_spotube": "Víme, že milujete Spotube", + "check_for_updates": "Zkontrolovat aktualizace", + "about_spotube": "O Spotube", + "blacklist": "Černá listina", + "please_sponsor": "Sponzorovat/darovat", + "spotube_description": "Spotube, rychlý, multiplatformní, bezplatný Spotify klient", + "version": "Verze", + "build_number": "Číslo sestavení", + "founder": "Zakladatel", + "repository": "Repozitář", + "bug_issues": "Chyby+Problémy", + "made_with": "Vytvořeno s ❤️ v Bangladéši🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Licence", + "add_spotify_credentials": "Přidejte své přihlašovací údaje Spotify a začněte", + "credentials_will_not_be_shared_disclaimer": "Nebojte, žádné z vašich údajů nebudou shromažďovány ani s nikým sdíleny", + "know_how_to_login": "Nevíte, jak na to?", + "follow_step_by_step_guide": "Postupujte podle návodu", + "spotify_cookie": "Cookie Spotify {name}", + "cookie_name_cookie": "Cookie {name}", + "fill_in_all_fields": "Vyplňte prosím všechna pole", + "submit": "Odeslat", + "exit": "Ukončit", + "previous": "Předchozí", + "next": "Další", + "done": "Hotovo", + "step_1": "Krok 1", + "first_go_to": "Nejprve jděte na", + "login_if_not_logged_in": "a přihlašte se nebo se zaregistrujte, pokud nejste přihlášeni", + "step_2": "Krok 2", + "step_2_steps": "1. Jakmile jste přihlášeni, stiskněte F12 nebo pravé tlačítko myši > Prozkoumat, abyste otevřeli nástroje pro vývojáře prohlížeče.\n2. Poté přejděte na kartu \"Aplikace\" (Chrome, Edge, Brave atd.) nebo kartu \"Úložiště\" (Firefox, Palemoon atd.)\n3. Přejděte do sekce \"Cookies\" a pak do podsekce \"https://accounts.spotify.com\"", + "step_3": "Krok 3", + "step_3_steps": "Zkopírujte hodnotu cookie \"sp_dc\"", + "success_emoji": "Úspěch🥳", + "success_message": "Nyní jste úspěšně přihlášeni pomocí svého Spotify účtu. Dobrá práce, kamaráde!", + "step_4": "Krok 4", + "step_4_steps": "Vložte zkopírovanou hodnotu \"sp_dc\"", + "something_went_wrong": "Něco se pokazilo", + "piped_instance": "Instance serveru Piped", + "piped_description": "Instance serveru Piped, kterou použít pro hledání skladeb", + "piped_warning": "Některé z nich nemusí dobře fungovat. Používejte na vlastní riziko", + "generate_playlist": "Vygenerovat playlist", + "track_exists": "Skladba {track} již existuje", + "replace_downloaded_tracks": "Nahradit všechny stažené skladby", + "skip_download_tracks": "Přeskočit stahování všech stažených skladeb", + "do_you_want_to_replace": "Chcete nahradit existující skladbu??", + "replace": "Nahradit", + "skip": "Přeskočit", + "select_up_to_count_type": "Vyberte až {count} {type}", + "select_genres": "Vyberte žánry", + "add_genres": "Přidat žánry", + "country": "Země", + "number_of_tracks_generate": "Počet skladeb k vygenerování", + "acousticness": "Akustičnost", + "danceability": "Tanečnost", + "energy": "Energie", + "instrumentalness": "Instrumentálnost", + "liveness": "Živost", + "loudness": "Hlasitost", + "speechiness": "Mluvnost", + "valence": "Valence", + "popularity": "Popularita", + "key": "Klíč", + "duration": "Délka (s)", + "tempo": "Tempo (BPM)", + "mode": "Režim", + "time_signature": "Udání taktu", + "short": "Krátký", + "medium": "Střední", + "long": "Dlouhý", + "min": "Min", + "max": "Max", + "target": "Cíl", + "moderate": "Mírný", + "deselect_all": "Zrušit výběr", + "select_all": "Vybrat vše", + "are_you_sure": "Jste si jisti?", + "generating_playlist": "Generování vašeho vlastního playlistu...", + "selected_count_tracks": "Vybráno {count} skladeb", + "download_warning": "Pokud stáhnete všechny skladby najednou, pirátíte tím hudbu a škodíte kreativní společnosti hudby. Doufám, že jste si toho vědomi. Vždy se snažte respektovat a podporovat tvrdou práci umělců", + "download_ip_ban_warning": "Mimochodem, vaše IP může být na YouTube zablokována kvůli nadměrným požadavkům na stahování. Blokování IP znamená, že nemůžete používat YouTube (i když jste přihlášeni) alespoň 2-3 měsíce ze zařízení s touto IP. A Spotube nenese žádnou odpovědnost, pokud se to někdy stane", + "by_clicking_accept_terms": "Kliknutím na 'přijmout' souhlasíte s následujícími podmínkami:", + "download_agreement_1": "Vím, že pirátím hudbu. Jsem špatný", + "download_agreement_2": "Budu podporovat umělce, kdekoliv to bude možné, a dělám to jen proto, že nemám peníze na koupi jejich umění", + "download_agreement_3": "Jsem si naprosto vědom toho, že moje IP může být na YouTube zablokována a nenesu žádnou odpovědnost za nehody způsobené mým současným jednáním", + "decline": "Odmítnout", + "accept": "Přijmout", + "details": "Podrobnosti", + "youtube": "YouTube", + "channel": "Kanál", + "likes": "Líbí se", + "dislikes": "Nelíbí se", + "views": "Zobrazení", + "streamUrl": "URL streamu", + "stop": "Zastavit", + "sort_newest": "Seřadit od nejnovějších", + "sort_oldest": "Seřadit od nejstarších", + "sleep_timer": "Časovač spánku", + "mins": "{minutes} Minut", + "hours": "{hours} Hodin", + "hour": "{hours} Hodina", + "custom_hours": "Vlastní hodiny", + "logs": "Protokoly", + "developers": "Vývojáři", + "not_logged_in": "Nejste přihlášeni", + "search_mode": "Režim hledání", + "audio_source": "Zdroj zvuku", + "ok": "Ok", + "failed_to_encrypt": "Šifrování selhalo", + "encryption_failed_warning": "Spotube používá šifrování k bezpečnému ukládání vašich dat. Ale selhalo. Takže se vrátí k nezabezpečenému úložišti\nPokud používáte linux, ujistěte se, že máte nainstalovanou jakoukoli službu k ukládání bezpečnostních pověření (gnome-keyring, kde-wallet, keepassxc atd.)", + "querying_info": "Získávání informací...", + "piped_api_down": "Piped API je mimo provoz", + "piped_down_error_instructions": "Instance Piped {pipedInstance} je momentálně mimo provoz\n\nBuď změňte instanci nebo změňte 'Typ API' na oficiální YouTube API\n\nPo změně se ujistěte, že aplikaci restartujete", + "you_are_offline": "Momentálně jste offline", + "connection_restored": "Vaše internetové připojení bylo obnoveno", + "use_system_title_bar": "Použít systémové záhlaví okna", + "crunching_results": "Zpracovávání výsledků...", + "search_to_get_results": "Hledejte pro získání výsledků", + "use_amoled_mode": "Úplně černé téma", + "pitch_dark_theme": "AMOLED režim", + "normalize_audio": "Normalizovat audio", + "change_cover": "Změnit obal", + "add_cover": "Přidat obal", + "restore_defaults": "Obnovit výchozí", + "download_music_codec": "Kodek pro stahování", + "streaming_music_codec": "Kodek pro streamování", + "login_with_lastfm": "Přihlásit se pomocí Last.fm", + "connect": "Připojit", + "disconnect_lastfm": "Odpojit Last.fm", + "disconnect": "Odpojit", + "username": "Uživatelské jméno", + "password": "Heslo", + "login": "Přihlásit se", + "login_with_your_lastfm": "Přihlásit se pomocí vašeho Last.fm účtu", + "scrobble_to_lastfm": "Scrobble na Last.fm", + "go_to_album": "Přejít na album", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "Procházet vše", + "genres": "Žánry", + "explore_genres": "Prozkoumat žánry", + "friends": "Přátelé", + "no_lyrics_available": "Omlouváme se, není možné najít texty pro tuto skladbu", + "start_a_radio": "Vytvořit rádio", + "how_to_start_radio": "Jak chcete vytvořit rádio?", + "replace_queue_question": "Chcete nahradit aktuální frontu nebo k ní přidat?", + "endless_playback": "Nekonečné přehrávání", + "delete_playlist": "Smazat playlist", + "delete_playlist_confirmation": "Jste si jisti, že chcete smazat tento playlist?", + "local_tracks": "Místní skladby", + "song_link": "Odkaz na skladbu", + "skip_this_nonsense": "Přeskočit tenhle nesmysl", + "freedom_of_music": "“Svobodná hudba”", + "freedom_of_music_palm": "“Svobodná hudba ve vaší dlani”", + "get_started": "Začít", + "youtube_source_description": "Doporučeno a funguje nejlépe.", + "piped_source_description": "Nechcete být sledováni? Stejné jako YouTube, ale respektuje soukromí.", + "jiosaavn_source_description": "Nejlepší pro jihoasijský region.", + "highest_quality": "Nejvyšší kvalita: {quality}", + "select_audio_source": "Vyberte zdroj zvuku", + "endless_playback_description": "Automaticky přidávat nové skladby\nna konec fronty", + "choose_your_region": "Vyberte svůj region", + "choose_your_region_description": "To pomůže Spotube ukázat vám správný obsah\npro vaši lokalitu.", + "choose_your_language": "Vyberte svůj jazyk", + "help_project_grow": "Pomozte tomuto projektu růst", + "help_project_grow_description": "Spotube je open-source projekt. Můžete pomoci tomuto projektu růst tím, že přispějete do projektu, nahlásíte chyby nebo navrhnete nové funkce.", + "contribute_on_github": "Přispějte na GitHub", + "donate_on_open_collective": "Darujte na Open Collective", + "browse_anonymously": "Procházet anonymně", + "enable_connect": "Povolit ovládání", + "enable_connect_description": "Ovládejte Spotube z jiného zařízení", + "devices": "Zařízení", + "select": "Vybrat", + "connect_client_alert": "Zařízení je ovládáno z {client}", + "this_device": "Toto zařízení", + "remote": "Ovladač" +} \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index e584d2be..ef3685fa 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -12,6 +12,7 @@ /// doannc2212@github => Vietnamese /// sappho192@github => Korean /// watchakorn-18k@github => Thai +/// Microsoft Copilot, Tutislav@github => Czech library l10n; @@ -23,6 +24,7 @@ class L10n { const Locale('ar', 'SA'), const Locale('bn', 'BD'), const Locale('ca', 'AD'), + const Locale('cs', 'CZ'), const Locale('de', 'GE'), const Locale('es', 'ES'), const Locale('fa', 'IR'), From 7ae9f56482240b2946c42d4382cbedee330ed5fb Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 15 Apr 2024 19:28:01 +0600 Subject: [PATCH 055/261] chore: bump version and generate changelogs --- .github/workflows/spotube-release-binary.yml | 2 +- CHANGELOG.md | 21 ++++++++++++++++++++ pubspec.yaml | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 5d918a03..d9fbd0c7 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to release (x.x.x) - default: 3.4.1 + default: 3.6.0 required: true channel: type: choice diff --git a/CHANGELOG.md b/CHANGELOG.md index ddbd4fe1..21ca4b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [3.6.0-0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0-0) (2024-04-15) + + +### Features + +* add Spotify homepage personalized recommendations ([#1402](https://github.com/krtirtho/spotube/issues/1402)) ([9e25c74](https://github.com/krtirtho/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) +* add user profile page ([39e97ee](https://github.com/krtirtho/spotube/commit/39e97eef34d87348a264843e145f31f82832d12e)) +* **android:** Filter Device To Force High Frame Rate ([#880](https://github.com/krtirtho/spotube/issues/880)) ([6e41b10](https://github.com/krtirtho/spotube/commit/6e41b106fa989adee393d3ce2535e75446ad3eea)) +* improved caching based on riverpod ([#1343](https://github.com/krtirtho/spotube/issues/1343)) ([6673e5a](https://github.com/krtirtho/spotube/commit/6673e5a8a86b9667cf9dbff9bb7c40ea6b7de771)) +* LAN connect a.k.a control remote Spotube playback and local output device selection ([#1355](https://github.com/krtirtho/spotube/issues/1355)) ([68374ef](https://github.com/krtirtho/spotube/commit/68374efd3ec556f31b937e5b96920787b54eec78)) +* **lyrics:** add LRCLIB lyrics provider as fallback ([5afe823](https://github.com/krtirtho/spotube/commit/5afe823abdb198340b55d138d8173d886a811632)) +* search history support [#1236](https://github.com/krtirtho/spotube/issues/1236) ([82b1cfa](https://github.com/krtirtho/spotube/commit/82b1cfa0d775e3958c666280943a893c9113d468)) +* **translations:** Add Czech translation ([#1401](https://github.com/krtirtho/spotube/issues/1401)) ([5a6b800](https://github.com/krtirtho/spotube/commit/5a6b80091259359bc38c4b91cd8cb496c4270fa4)) +* **translations:** add Thai Language ([#1319](https://github.com/krtirtho/spotube/issues/1319)) ([b70f250](https://github.com/krtirtho/spotube/commit/b70f250e8d5137fd990787ec9e3d058126cf14f3)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) + + +### Bug Fixes + +* instance of Artist bug [#1362](https://github.com/krtirtho/spotube/issues/1362) ([c8dd802](https://github.com/krtirtho/spotube/commit/c8dd8025ec96bd78ed77cae35f1429aa48c16fde)) +* **playback:** sponsor block skips and stutters in same position ([0d080b7](https://github.com/krtirtho/spotube/commit/0d080b77b72529c0be5ebc27ace1c52307511f73)) + ## [3.5.0](https://github.com/krtirtho/spotube/compare/v3.4.1...v3.5.0) (2024-03-08) diff --git a/pubspec.yaml b/pubspec.yaml index 16f51981..3f4c22af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.5.0+29 +version: 3.6.0+30 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube From 883783b769673c1ade30c2f17a3cae4b68f4c7da Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 15 Apr 2024 19:40:38 +0600 Subject: [PATCH 056/261] chore: add untranslated messages --- README.md | 24 +++-- lib/l10n/app_ar.arb | 9 +- lib/l10n/app_bn.arb | 9 +- lib/l10n/app_ca.arb | 9 +- lib/l10n/app_de.arb | 9 +- lib/l10n/app_es.arb | 9 +- lib/l10n/app_fa.arb | 9 +- lib/l10n/app_fr.arb | 9 +- lib/l10n/app_hi.arb | 9 +- lib/l10n/app_it.arb | 9 +- lib/l10n/app_ja.arb | 9 +- lib/l10n/app_ko.arb | 9 +- lib/l10n/app_ne.arb | 9 +- lib/l10n/app_nl.arb | 9 +- lib/l10n/app_pl.arb | 9 +- lib/l10n/app_pt.arb | 9 +- lib/l10n/app_ru.arb | 9 +- lib/l10n/app_th.arb | 10 +- lib/l10n/app_uk.arb | 9 +- lib/l10n/app_vi.arb | 11 +- lib/l10n/app_zh.arb | 9 +- untranslated_messages.json | 205 +------------------------------------ 22 files changed, 180 insertions(+), 232 deletions(-) diff --git a/README.md b/README.md index 4ad4e1be..8b8a6214 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [YouTube](https://youtube.com/) - YouTube is an American online video-sharing platform headquartered in San Bruno, California. Three former PayPal employees—Chad Hurley, Steve Chen, and Jawed Karim—created the service in February 2005 1. [JioSaavn](https://www.jiosaavn.com) - JioSaavn is an Indian online music streaming service and a digital distributor of Bollywood, English and other regional Indian music across the world. Since it was founded in 2007 as Saavn, the company has acquired rights to over 5 crore (50 million) music tracks in 15 languages 1. [SongLink](https://song.link) - SongLink is a free smart link service that helps you share music with your audience. It's a one-stop-shop for creating smart links for music, podcasts, and other audio content +1. [LRCLib](https://lrclib.net/) - A public synced lyric API 1. [Linux](https://www.linux.org) - Linux is a family of open-source Unix-like operating systems based on the Linux kernel, an operating system kernel first released on September 17, 1991, by Linus Torvalds. Linux is typically packaged in a Linux distribution 1. [AUR](https://aur.archlinux.org) - AUR stands for Arch User Repository. It is a community-driven repository for Arch-based Linux distributions users 1. [Flatpak](https://flatpak.org) - Flatpak is a utility for software deployment and package management for Linux @@ -233,9 +234,6 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [duration](https://github.com/desktop-dart/duration) - Utilities to make working with 'Duration's easier. Formats duration in human readable form and also parses duration in human readable form to Dart's Duration. 1. [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_devtools](https://fl-query.krtirtho.dev) - Devtools support for Fl-Query 1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft. 1. [flutter_cache_manager](https://github.com/Baseflow/flutter_cache_manager/tree/develop/flutter_cache_manager) - Generic cache manager for flutter. Saves web files on the storages of the device and saves the cache info using sqflite. 1. [flutter_displaymode](https://github.com/ajinasokan/flutter_displaymode) - A Flutter plugin to set display mode (resolution, refresh rate) on Android platform. Allows to enable high refresh rate on supported devices. @@ -257,7 +255,7 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests. 1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. 1. [intl](https://pub.dev/packages/intl) - Contains code to deal with internationalized/localized messages, date and number formatting and parsing, bi-directional text, and other internationalization issues. -1. [introduction_screen](https://github.com/pyozer/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities +1. [introduction_screen](https://pub.dev/packages/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities 1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package. 1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs. 1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular. @@ -295,22 +293,32 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter 1. [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. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry. 1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. 1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. 1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. 1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. +1. [bonsoir](https://bonsoir.skyost.eu) - A Zeroconf library that allows you to discover network services and to broadcast your own. Based on Apple Bonjour and Android NSD. +1. [shelf](https://pub.dev/packages/shelf) - A model for web server middleware that encourages composition and easy reuse. +1. [shelf_router](https://pub.dev/packages/shelf_router) - A convenient request router for the shelf web-framework, with support for URL-parameters, nested routers and routers generated from source annotations. +1. [shelf_web_socket](https://pub.dev/packages/shelf_web_socket) - A shelf handler that wires up a listener for every connection. +1. [web_socket_channel](https://pub.dev/packages/web_socket_channel) - StreamChannel wrappers for WebSockets. Provides a cross-platform WebSocketChannel API, a cross-platform implementation of that API that communicates over an underlying StreamChannel. +1. [lrc](https://pub.dev/packages/lrc) - A Dart-only package that creates, parses, and handles LRC, which is a format that stores song lyrics. +1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. +1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. +1. [timezone](https://pub.dev/packages/timezone) - Time zone database and time zone aware DateTime. +1. [crypto](https://pub.dev/packages/crypto) - Implementations of SHA, MD5, and HMAC cryptographic functions. 1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. 1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. -1. [flutter_distributor](https://distributor.leanflutter.org) - A complete tool for packaging and publishing your Flutter apps. +1. [flutter_distributor](https://distributor.leanflutter.dev) - A complete tool for packaging and publishing your Flutter apps. 1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs. 1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon. 1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices. 1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class. 1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. -1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. -1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. 1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. +1. [custom_lint](https://pub.dev/packages/custom_lint) - Lint rules are a powerful way to improve the maintainability of a project. Custom Lint allows package authors and developers to easily write custom lint rules. +1. [riverpod_lint](https://riverpod.dev) - Riverpod_lint is a developer tool for users of Riverpod, designed to help stop common issues and simplify repetitive tasks. 1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development 1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video 1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 41fab083..68308ba1 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube هو مشروع مفتوح المصدر. يمكنك مساعدة هذا المشروع في النمو عن طريق المساهمة في المشروع، أو الإبلاغ عن الأخطاء، أو اقتراح ميزات جديدة.", "contribute_on_github": "المساهمة على GitHub", "donate_on_open_collective": "التبرع على Open Collective", - "browse_anonymously": "تصفح بشكل مجهول" + "browse_anonymously": "تصفح بشكل مجهول", + "enable_connect": "تمكين الاتصال", + "enable_connect_description": "التحكم في Spotube من الأجهزة الأخرى", + "devices": "الأجهزة", + "select": "اختر", + "connect_client_alert": "أنت تتم التحكم بواسطة {client}", + "this_device": "هذا الجهاز", + "remote": "بعيد" } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 353ca617..506e78bc 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "স্পটুব একটি ওপেন সোর্স প্রকল্প। আপনি প্রকল্পে অবদান রাখেন, বাগ রিপোর্ট করেন, বা নতুন বৈশিষ্ট্যগুলি সুপারিশ করেন।", "contribute_on_github": "গিটহাবে অবদান রাখুন", "donate_on_open_collective": "ওপেন কলেক্টিভে অনুদান করুন", - "browse_anonymously": "অজানে ব্রাউজ করুন" + "browse_anonymously": "অজানে ব্রাউজ করুন", + "enable_connect": "সংযোগ সক্রিয় করুন", + "enable_connect_description": "অন্যান্য ডিভাইস থেকে Spotube নিয়ন্ত্রণ করুন", + "devices": "ডিভাইস", + "select": "নির্বাচন করুন", + "connect_client_alert": "আপনি {client} দ্বারা নিয়ন্ত্রিত হচ্ছেন", + "this_device": "এই ডিভাইস", + "remote": "রিমোট" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index 9848954a..8faa0d09 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube és un projecte de codi obert. Podeu ajudar a fer créixer aquest projecte contribuint al projecte, informant d'errors o suggerint noves funcionalitats.", "contribute_on_github": "Contribueix a GitHub", "donate_on_open_collective": "Fes una donació a Open Collective", - "browse_anonymously": "Navega de manera anònima" + "browse_anonymously": "Navega de manera anònima", + "enable_connect": "Habilita la connexió", + "enable_connect_description": "Controla Spotube des d'altres dispositius", + "devices": "Dispositius", + "select": "Selecciona", + "connect_client_alert": "Estàs sent controlat per {client}", + "this_device": "Aquest dispositiu", + "remote": "Remot" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b058d41a..77435d67 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube ist ein Open-Source-Projekt. Sie können diesem Projekt helfen, indem Sie zum Projekt beitragen, Fehler melden oder neue Funktionen vorschlagen.", "contribute_on_github": "Auf GitHub beitragen", "donate_on_open_collective": "Auf Open Collective spenden", - "browse_anonymously": "Anonym durchsuchen" + "browse_anonymously": "Anonym durchsuchen", + "enable_connect": "Verbindung aktivieren", + "enable_connect_description": "Spotube von anderen Geräten steuern", + "devices": "Geräte", + "select": "Auswählen", + "connect_client_alert": "Du wirst von {client} gesteuert", + "this_device": "Dieses Gerät", + "remote": "Fernbedienung" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0b4cbb2a..11617b42 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube es un proyecto de código abierto. Puedes ayudar a que este proyecto crezca contribuyendo al proyecto, informando errores o sugiriendo nuevas funciones.", "contribute_on_github": "Contribuir en GitHub", "donate_on_open_collective": "Donar en Open Collective", - "browse_anonymously": "Navegar Anónimamente" + "browse_anonymously": "Navegar Anónimamente", + "enable_connect": "Habilitar conexión", + "enable_connect_description": "Controla Spotube desde otros dispositivos", + "devices": "Dispositivos", + "select": "Seleccionar", + "connect_client_alert": "Estás siendo controlado por {client}", + "this_device": "Este dispositivo", + "remote": "Remoto" } \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 629238cc..8a0bee3a 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube یک پروژه متن باز است. شما می‌توانید با به پروژه کمک کردن، گزارش دادن اشکالات یا پیشنهاد ویژگی‌های جدید، به این پروژه کمک کنید.", "contribute_on_github": "مشارکت در GitHub", "donate_on_open_collective": "کمک مالی در Open Collective", - "browse_anonymously": "مرور به صورت ناشناس" + "browse_anonymously": "مرور به صورت ناشناس", + "enable_connect": "فعال‌سازی اتصال", + "enable_connect_description": "کنترل Spotube از دیگر دستگاه‌ها", + "devices": "دستگاه‌ها", + "select": "انتخاب", + "connect_client_alert": "شما توسط {client} کنترل می‌شوید", + "this_device": "این دستگاه", + "remote": "راه‌دور" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 69b2bb69..cabcb8e1 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube est un projet open-source. Vous pouvez aider ce projet à grandir en contribuant au projet, en signalant des bugs ou en suggérant de nouvelles fonctionnalités.", "contribute_on_github": "Contribuer sur GitHub", "donate_on_open_collective": "Faire un don sur Open Collective", - "browse_anonymously": "Naviguer anonymement" + "browse_anonymously": "Naviguer anonymement", + "enable_connect": "Activer la connexion", + "enable_connect_description": "Contrôlez Spotube depuis d'autres appareils", + "devices": "Appareils", + "select": "Sélectionner", + "connect_client_alert": "Vous êtes contrôlé par {client}", + "this_device": "Cet appareil", + "remote": "À distance" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index b442da37..a72e136e 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube एक ओपन सोर्स परियोजना है। आप इस परियोजना को योगदान देकर, बग रिपोर्ट करके या नई विशेषताओं का सुझाव देकर इस परियोजना को बढ़ा सकते हैं।", "contribute_on_github": "GitHub पर योगदान करें", "donate_on_open_collective": "ओपन कलेक्टिव पर दान करें", - "browse_anonymously": "बिना नाम के ब्राउज़ करें" + "browse_anonymously": "बिना नाम के ब्राउज़ करें", + "enable_connect": "कनेक्ट सक्षम करें", + "enable_connect_description": "अन्य उपकरणों से Spotube को नियंत्रित करें", + "devices": "उपकरण", + "select": "चयन करें", + "connect_client_alert": "आप {client} द्वारा नियंत्रित हो रहे हैं", + "this_device": "यह उपकरण", + "remote": "रिमोट" } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index f8440cd0..bb1881d6 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -314,5 +314,12 @@ "help_project_grow_description": "Spotube è un progetto open-source. Puoi aiutare questo progetto a crescere contribuendo al progetto, segnalando bug o suggerendo nuove funzionalità.", "contribute_on_github": "Contribuisci su GitHub", "donate_on_open_collective": "Dona su Open Collective", - "browse_anonymously": "Naviga in modo anonimo" + "browse_anonymously": "Naviga in modo anonimo", + "enable_connect": "Abilita connessione", + "enable_connect_description": "Controlla Spotube da altri dispositivi", + "devices": "Dispositivi", + "select": "Seleziona", + "connect_client_alert": "Stai venendo controllato da {client}", + "this_device": "Questo dispositivo", + "remote": "Remoto" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ecdc77a2..ab759404 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotubeはオープンソースプロジェクトです。プロジェクトに貢献したり、バグを報告したり、新しい機能を提案することで、このプロジェクトの成長に貢献できます。", "contribute_on_github": "GitHubで貢献する", "donate_on_open_collective": "Open Collectiveで寄付する", - "browse_anonymously": "匿名で閲覧する" + "browse_anonymously": "匿名で閲覧する", + "enable_connect": "接続を有効にする", + "enable_connect_description": "他のデバイスからSpotubeを制御する", + "devices": "デバイス", + "select": "選択する", + "connect_client_alert": "{client} によって操作されています", + "this_device": "このデバイス", + "remote": "リモート" } \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5a3ee8bc..c94f8142 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -314,5 +314,12 @@ "help_project_grow_description": "Spotube는 오픈 소스 프로젝트입니다. 프로젝트에 기여하거나 버그를 보고하거나 새로운 기능을 제안하여이 프로젝트의 성장에 도움을 줄 수 있습니다.", "contribute_on_github": "GitHub에서 기여하기", "donate_on_open_collective": "Open Collective에 기부하기", - "browse_anonymously": "익명으로 둘러보기" + "browse_anonymously": "익명으로 둘러보기", + "enable_connect": "연결 활성화", + "enable_connect_description": "다른 장치에서 Spotube 제어", + "devices": "장치", + "select": "선택", + "connect_client_alert": "{client}님에 의해 제어되고 있습니다", + "this_device": "이 장치", + "remote": "원격" } \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb index d921f3ba..4085b00e 100644 --- a/lib/l10n/app_ne.arb +++ b/lib/l10n/app_ne.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube एक खुला स्रोतको परियोजना हो। तपाईं परियोजनामा योगदान गरेर, त्रुटिहरू सूचिकै, वा नयाँ सुविधाहरू सुझाव दिएर यस परियोजनामा वृद्धि गर्न सक्नुहुन्छ।", "contribute_on_github": "GitHubमा योगदान गर्नुहोस्", "donate_on_open_collective": "खुला संगठनमा दान गर्नुहोस्", - "browse_anonymously": "अनामित रूपमा ब्राउज़ गर्नुहोस्" + "browse_anonymously": "अनामित रूपमा ब्राउज़ गर्नुहोस्", + "enable_connect": "कनेक्ट सक्रिय गर्नुहोस्", + "enable_connect_description": "अन्य उपकरणहरूबाट Spotube कन्ट्रोल गर्नुहोस्", + "devices": "उपकरणहरू", + "select": "चयन गर्नुहोस्", + "connect_client_alert": "तपाईंलाई {client} द्वारा नियन्त्रित गरिएको छ", + "this_device": "यो उपकरण", + "remote": "दूरसंचार" } \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 33e94a2e..0a04c40b 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -314,5 +314,12 @@ "help_project_grow_description": "Spotube is een open-source project. U kunt dit project helpen groeien door bij te dragen aan het project, bugs te melden of nieuwe functies voor te stellen.", "contribute_on_github": "Bijdragen op GitHub", "donate_on_open_collective": "Doneren op Open Collective", - "browse_anonymously": "Anoniem Bladeren" + "browse_anonymously": "Anoniem Bladeren", + "enable_connect": "Verbinding inschakelen", + "enable_connect_description": "Spotube bedienen vanaf andere apparaten", + "devices": "Apparaten", + "select": "Selecteren", + "connect_client_alert": "Je wordt gecontroleerd door {client}", + "this_device": "Dit apparaat", + "remote": "Afstandsbediening" } \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index a1bc5de6..9ce31187 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube to projekt open-source. Możesz pomóc temu projektowi rosnąć, przyczyniając się do projektu, zgłaszając błędy lub sugerując nowe funkcje.", "contribute_on_github": "Przyczyniaj się na GitHubie", "donate_on_open_collective": "Dotuj na Open Collective", - "browse_anonymously": "Przeglądaj Anonimowo" + "browse_anonymously": "Przeglądaj Anonimowo", + "enable_connect": "Włącz połączenie", + "enable_connect_description": "Kontroluj Spotube z innych urządzeń", + "devices": "Urządzenia", + "select": "Wybierz", + "connect_client_alert": "Jesteś sterowany przez {client}", + "this_device": "To urządzenie", + "remote": "Zdalny" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 7f290a1d..53732589 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube é um projeto de código aberto. Você pode ajudar este projeto a crescer contribuindo para o projeto, relatando bugs ou sugerindo novos recursos.", "contribute_on_github": "Contribuir no GitHub", "donate_on_open_collective": "Doar no Open Collective", - "browse_anonymously": "Navegar Anonimamente" + "browse_anonymously": "Navegar Anonimamente", + "enable_connect": "Ativar conexão", + "enable_connect_description": "Controle o Spotube a partir de outros dispositivos", + "devices": "Dispositivos", + "select": "Selecionar", + "connect_client_alert": "Você está sendo controlado por {client}", + "this_device": "Este dispositivo", + "remote": "Remoto" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c9139a90..a18e02e7 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube - это проект с открытым исходным кодом. Вы можете помочь этому проекту развиваться, внося вклад в проект, сообщая ошибках или предлагая новые функции.", "contribute_on_github": "Внести вклад на GitHub", "donate_on_open_collective": "Пожертвовать на Open Collective", - "browse_anonymously": "Анонимно просматривать" + "browse_anonymously": "Анонимно просматривать", + "enable_connect": "Включить подключение", + "enable_connect_description": "Управление Spotube с других устройств", + "devices": "Устройства", + "select": "Выбрать", + "connect_client_alert": "Вас контролирует {client}", + "this_device": "Это устройство", + "remote": "Дистанционное управление" } \ No newline at end of file diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index cd58a20d..866929fa 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -313,5 +313,13 @@ "help_project_grow_description": "Spotube เป็นโครงการโอเพนซอร์ส คุณสามารถช่วยให้โครงการนี้เติบโตได้โดยการมีส่วนร่วมในโครงการ รายงานข้อบกพร่อง หรือเสนอคุณสมบัติใหม่", "contribute_on_github": "มีส่วนร่วมบน GitHub", "donate_on_open_collective": "บริจาคบน Open Collective", - "browse_anonymously": "เรียกดูแบบไม่ระบุตัวตน" + "browse_anonymously": "เรียกดูแบบไม่ระบุตัวตน", + "choose_your_language": "เลือกภาษาของคุณ", + "enable_connect": "เปิดใช้งานการเชื่อมต่อ", + "enable_connect_description": "ควบคุม Spotube จากอุปกรณ์อื่น", + "devices": "อุปกรณ์", + "select": "เลือก", + "connect_client_alert": "คุณกำลังถูกควบคุมโดย {client}", + "this_device": "อุปกรณ์นี้", + "remote": "ระยะไกล" } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index fe57e617..4208a3d2 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube - це проект з відкритим кодом. Ви можете допомогти цьому проекту зростати, вносячи свій внесок у проект, повідомляючи про помилки або пропонуючи нові функції.", "contribute_on_github": "Долучайтесь на GitHub", "donate_on_open_collective": "Пожертвуйте на Open Collective", - "browse_anonymously": "Анонімно переглядати" + "browse_anonymously": "Анонімно переглядати", + "enable_connect": "Увімкнути підключення", + "enable_connect_description": "Керуйте Spotube з інших пристроїв", + "devices": "Пристрої", + "select": "Вибрати", + "connect_client_alert": "Вас керує {client}", + "this_device": "Цей пристрій", + "remote": "Віддалений" } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 0e9b0b7c..6115fc0c 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -311,5 +311,14 @@ "help_project_grow_description": "Spotube là một dự án mã nguồn mở. Bạn có thể giúp dự án này phát triển bằng cách đóng góp vào dự án, báo cáo lỗi hoặc đề xuất tính năng mới.", "contribute_on_github": "Đóng góp trên GitHub", "donate_on_open_collective": "Quyên góp trên Open Collective", - "browse_anonymously": "Duyệt Anonymously" + "browse_anonymously": "Duyệt Anonymously", + "friends": "Bạn bè", + "no_lyrics_available": "Xin lỗi, không tìm thấy lời cho bài hát này", + "enable_connect": "Kích hoạt kết nối", + "enable_connect_description": "Điều khiển Spotube từ các thiết bị khác", + "devices": "Thiết bị", + "select": "Chọn", + "connect_client_alert": "Bạn đang được điều khiển bởi {client}", + "this_device": "Thiết bị này", + "remote": "Từ xa" } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 506661f0..da5254a3 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -313,5 +313,12 @@ "help_project_grow_description": "Spotube是一个开源项目。您可以通过为项目做出贡献、报告错误或建议新功能来帮助该项目成长。", "contribute_on_github": "在GitHub上做出贡献", "donate_on_open_collective": "在Open Collective上捐款", - "browse_anonymously": "匿名浏览" + "browse_anonymously": "匿名浏览", + "enable_connect": "启用连接", + "enable_connect_description": "从其他设备控制Spotube", + "devices": "设备", + "select": "选择", + "connect_client_alert": "您正在被 {client} 控制", + "this_device": "此设备", + "remote": "远程" } \ No newline at end of file diff --git a/untranslated_messages.json b/untranslated_messages.json index 3696d52e..9e26dfee 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,204 +1 @@ -{ - "ar": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "bn": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "ca": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "de": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "es": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "fa": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "fr": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "hi": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "it": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "ja": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "ko": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "ne": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "nl": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "pl": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "pt": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "ru": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "th": [ - "choose_your_language", - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "uk": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "vi": [ - "friends", - "no_lyrics_available", - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ], - - "zh": [ - "enable_connect", - "enable_connect_description", - "devices", - "select", - "connect_client_alert", - "this_device", - "remote" - ] -} +{} \ No newline at end of file From 930539ca483a9fbedd40a241ee133e28a9076a94 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 15 Apr 2024 19:47:15 +0600 Subject: [PATCH 057/261] chore: fix analyzer issues --- lib/components/desktop_login/login_form.dart | 3 +-- lib/components/shared/waypoint.dart | 8 +++----- lib/hooks/configurators/use_get_storage_perms.dart | 6 +++--- lib/hooks/utils/use_palette_color.dart | 7 +++---- lib/pages/root/root_app.dart | 3 +-- 5 files changed, 11 insertions(+), 16 deletions(-) diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart index 2949fbae..6091829c 100644 --- a/lib/components/desktop_login/login_form.dart +++ b/lib/components/desktop_login/login_form.dart @@ -16,7 +16,6 @@ class TokenLoginForm extends HookConsumerWidget { Widget build(BuildContext context, ref) { final authenticationNotifier = ref.watch(authenticationProvider.notifier); final directCodeController = useTextEditingController(); - final mounted = useIsMounted(); final isLoading = useState(false); @@ -57,7 +56,7 @@ class TokenLoginForm extends HookConsumerWidget { await AuthenticationCredentials.fromCookie( cookieHeader), ); - if (mounted()) { + if (context.mounted) { onDone?.call(); } } finally { diff --git a/lib/components/shared/waypoint.dart b/lib/components/shared/waypoint.dart index 08e9088a..cf00e29b 100644 --- a/lib/components/shared/waypoint.dart +++ b/lib/components/shared/waypoint.dart @@ -20,8 +20,6 @@ class Waypoint extends HookWidget { @override Widget build(BuildContext context) { - final isMounted = useIsMounted(); - useEffect(() { if (isGrid) { return null; @@ -32,19 +30,19 @@ class Waypoint extends HookWidget { // scrollController fetches the next paginated data when the current // position of the user on the screen has surpassed - if (controller.position.pixels >= nextPageTrigger && isMounted()) { + if (controller.position.pixels >= nextPageTrigger && context.mounted) { await onTouchEdge?.call(); } } WidgetsBinding.instance.addPostFrameCallback((_) { - if (controller.hasClients && isMounted()) { + if (controller.hasClients && context.mounted) { listener(); controller.addListener(listener); } }); return () => controller.removeListener(listener); - }, [controller, onTouchEdge, isMounted]); + }, [controller, onTouchEdge]); if (isGrid) { return VisibilityDetector( diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart index 86b495c4..db51af14 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -7,7 +7,7 @@ import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart'; void useGetStoragePermissions(WidgetRef ref) { - final isMounted = useIsMounted(); + final context = useContext(); useAsyncEffect( () async { @@ -25,11 +25,11 @@ void useGetStoragePermissions(WidgetRef ref) { if (hasNoStoragePerm) { await Permission.storage.request(); - if (isMounted()) ref.invalidate(localTracksProvider); + if (context.mounted) ref.invalidate(localTracksProvider); } if (hasNoAudioPerm) { await Permission.audio.request(); - if (isMounted()) ref.invalidate(localTracksProvider); + if (context.mounted) ref.invalidate(localTracksProvider); } }, null, diff --git a/lib/hooks/utils/use_palette_color.dart b/lib/hooks/utils/use_palette_color.dart index 9269edd7..e6d8b398 100644 --- a/lib/hooks/utils/use_palette_color.dart +++ b/lib/hooks/utils/use_palette_color.dart @@ -14,7 +14,6 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { final context = useContext(); final theme = Theme.of(context); final paletteColor = ref.watch(_paletteColorState); - final mounted = useIsMounted(); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { @@ -25,7 +24,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { width: 50, ), ); - if (!mounted()) return; + if (!context.mounted) return; final color = theme.brightness == Brightness.light ? palette.lightMutedColor ?? palette.lightVibrantColor : palette.darkMutedColor ?? palette.darkVibrantColor; @@ -41,7 +40,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { PaletteGenerator usePaletteGenerator(String imageUrl) { final palette = useState(PaletteGenerator.fromColors([])); - final mounted = useIsMounted(); + final context = useContext(); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { @@ -52,7 +51,7 @@ PaletteGenerator usePaletteGenerator(String imageUrl) { width: 50, ), ); - if (!mounted()) return; + if (!context.mounted) return; palette.value = newPalette; }); diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 56ea43a6..5ac0689a 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -38,7 +38,6 @@ class RootApp extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final isMounted = useIsMounted(); final showingDialogCompleter = useRef(Completer()..complete()); final downloader = ref.watch(downloadManagerProvider); final scaffoldMessenger = ScaffoldMessenger.of(context); @@ -129,7 +128,7 @@ class RootApp extends HookConsumerWidget { useEffect(() { downloader.onFileExists = (track) async { - if (!isMounted()) return false; + if (!context.mounted) return false; if (!showingDialogCompleter.value.isCompleted) { await showingDialogCompleter.value.future; From 6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0 Mon Sep 17 00:00:00 2001 From: Kshamendra Date: Wed, 17 Apr 2024 18:25:06 +0530 Subject: [PATCH 058/261] fix(updater): dead link (#1408) * docs: broken link in README.md (fixes #1310) (#1311) * docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. * Update use_update_checker.dart --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho --- README.md | 7 +------ lib/hooks/configurators/use_update_checker.dart | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 8b8a6214..f2666fbc 100644 --- a/README.md +++ b/README.md @@ -97,12 +97,7 @@ This handy table lists all the methods you can use to install Spotube: AppImage - - - Download AppImage - -

Note: AppimageLauncher is required!

- + AppImage's lacking stability led to it's temporal removal. More information at https://github.com/KRTirtho/spotube/issues/1082 Debian/Ubuntu diff --git a/lib/hooks/configurators/use_update_checker.dart b/lib/hooks/configurators/use_update_checker.dart index 1a6a5be5..7b937efb 100644 --- a/lib/hooks/configurators/use_update_checker.dart +++ b/lib/hooks/configurators/use_update_checker.dart @@ -62,7 +62,7 @@ void useUpdateChecker(WidgetRef ref) { barrierColor: Colors.black26, builder: (context) { const url = - "https://spotube.krtirtho.dev/other-downloads/stable-downloads"; + "https://spotube.krtirtho.dev/downloads"; return AlertDialog( title: const Text("Spotube has an update"), actions: [ From 7ac791757abb30f40374c169c4211916287bb3f3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 17 Apr 2024 22:20:13 +0600 Subject: [PATCH 059/261] fix(linux): tray icon not showing #541 upgrade old packages --- .fvm/fvm_config.json | 2 +- .github/workflows/spotube-publish-binary.yml | 2 +- .github/workflows/spotube-release-binary.yml | 2 +- lib/collections/env.dart | 4 +- lib/collections/initializers.dart | 5 +- lib/components/root/bottom_player.dart | 18 +- .../inter_scrollbar/inter_scrollbar.dart | 4 +- .../shared/page_window_title_bar.dart | 35 +- .../sections/header/flexible_header.dart | 5 +- .../shared/tracks_view/track_view.dart | 5 +- .../configurators/use_close_behavior.dart | 26 +- lib/hooks/configurators/use_deep_linking.dart | 4 +- .../use_disable_battery_optimizations.dart | 6 +- .../configurators/use_get_storage_perms.dart | 5 +- .../configurators/use_init_sys_tray.dart | 128 ---- .../configurators/use_window_listener.dart | 10 +- lib/main.dart | 33 +- lib/models/connect/connect.freezed.dart | 2 +- lib/models/spotify/home_feed.freezed.dart | 2 +- .../spotify/recommendation_seeds.freezed.dart | 2 +- lib/pages/home/genres/genre_playlists.dart | 8 +- lib/pages/lyrics/mini_lyrics.dart | 92 +-- lib/pages/root/root_app.dart | 4 +- lib/pages/settings/sections/desktop.dart | 4 +- lib/pages/settings/sections/downloads.dart | 4 +- lib/pages/settings/settings.dart | 5 +- lib/provider/discord_provider.dart | 6 +- lib/provider/tray_manager/tray_manager.dart | 79 +++ lib/provider/tray_manager/tray_menu.dart | 108 +++ .../user_preferences_provider.dart | 10 +- .../user_preferences_state.dart | 4 +- .../user_preferences_state.freezed.dart | 6 +- .../user_preferences_state.g.dart | 4 +- lib/services/audio_player/audio_player.dart | 2 +- lib/services/audio_player/custom_player.dart | 6 +- .../audio_services/audio_services.dart | 10 +- lib/services/kv_store/kv_store.dart | 20 + lib/services/song_link/song_link.freezed.dart | 2 +- lib/services/sourced_track/sources/piped.dart | 2 +- lib/services/wm_tools/wm_tools.dart | 88 +++ linux/flutter/generated_plugin_registrant.cc | 8 +- linux/flutter/generated_plugins.cmake | 2 +- macos/Flutter/GeneratedPluginRegistrant.swift | 6 +- macos/Podfile.lock | 20 +- pubspec.lock | 658 ++++++++---------- pubspec.yaml | 96 ++- .../flutter/generated_plugin_registrant.cc | 6 +- windows/flutter/generated_plugins.cmake | 2 +- 48 files changed, 840 insertions(+), 722 deletions(-) delete mode 100644 lib/hooks/configurators/use_init_sys_tray.dart create mode 100644 lib/provider/tray_manager/tray_manager.dart create mode 100644 lib/provider/tray_manager/tray_menu.dart create mode 100644 lib/services/wm_tools/wm_tools.dart diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 7ca74200..d42a42fa 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.19.1", + "flutterSdkVersion": "3.19.5", "flavors": {} } \ No newline at end of file diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 805a89ac..960507f9 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.1.0 + default: 3.6.0 required: true dry_run: description: Dry run diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index d9fbd0c7..969e1b77 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -26,7 +26,7 @@ on: default: true env: - FLUTTER_VERSION: '3.19.1' + FLUTTER_VERSION: '3.19.5' jobs: windows: diff --git a/lib/collections/env.dart b/lib/collections/env.dart index 50fe1e6a..14f33b80 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -1,5 +1,5 @@ import 'package:envied/envied.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:spotube/utils/platform.dart'; part 'env.g.dart'; @@ -26,7 +26,7 @@ abstract class Env { static final String _enableUpdateChecker = _Env._enableUpdateChecker; static bool get enableUpdateChecker => - DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1"; + kIsFlatpak || _enableUpdateChecker == "1"; static String discordAppId = "1176718791388975124"; } diff --git a/lib/collections/initializers.dart b/lib/collections/initializers.dart index 9627de1c..976661fc 100644 --- a/lib/collections/initializers.dart +++ b/lib/collections/initializers.dart @@ -1,9 +1,10 @@ import 'dart:io'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + +import 'package:spotube/utils/platform.dart'; import 'package:win32_registry/win32_registry.dart'; Future registerWindowsScheme(String scheme) async { - if (!DesktopTools.platform.isWindows) return; + if (!kIsWindows) return; String appPath = Platform.resolvedExecutable; String protocolRegKey = 'Software\\Classes\\$scheme'; diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 06250131..5429e172 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -1,6 +1,5 @@ import 'dart:ui'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -24,6 +23,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.dart'; class BottomPlayer extends HookConsumerWidget { BottomPlayer({super.key}); @@ -95,19 +95,19 @@ class BottomPlayer extends HookConsumerWidget { tooltip: context.l10n.mini_player, icon: const Icon(SpotubeIcons.miniPlayer), onPressed: () async { - final prevSize = - await DesktopTools.window.getSize(); - await DesktopTools.window.setMinimumSize( + if (!kIsDesktop) return; + + final prevSize = await windowManager.getSize(); + await windowManager.setMinimumSize( const Size(300, 300), ); - await DesktopTools.window.setAlwaysOnTop(true); + await windowManager.setAlwaysOnTop(true); if (!kIsLinux) { - await DesktopTools.window.setHasShadow(false); + await windowManager.setHasShadow(false); } - await DesktopTools.window + await windowManager .setAlignment(Alignment.topRight); - await DesktopTools.window - .setSize(const Size(400, 500)); + await windowManager.setSize(const Size(400, 500)); await Future.delayed( const Duration(milliseconds: 100), () async { diff --git a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart index 2b3ce319..8a86b643 100644 --- a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart +++ b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart @@ -1,7 +1,7 @@ import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/utils/platform.dart'; class InterScrollbar extends HookWidget { final Widget child; @@ -15,7 +15,7 @@ class InterScrollbar extends HookWidget { @override Widget build(BuildContext context) { - if (DesktopTools.platform.isDesktop) return child; + if (kIsDesktop) return child; return DraggableScrollbar.semicircle( controller: controller, diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index 37daefa9..f19757f3 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -7,7 +7,8 @@ import 'package:titlebar_buttons/titlebar_buttons.dart'; import 'dart:math'; import 'package:flutter/foundation.dart' show kIsWeb; import 'dart:io' show Platform; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + +import 'package:window_manager/window_manager.dart'; class PageWindowTitleBar extends StatefulHookConsumerWidget implements PreferredSizeWidget { @@ -89,7 +90,7 @@ class _PageWindowTitleBarState extends ConsumerState { final systemTitleBar = ref.read(userPreferencesProvider.select((s) => s.systemTitleBar)); if (kIsDesktop && !systemTitleBar) { - DesktopTools.window.startDragging(); + windowManager.startDragging(); } } @@ -107,11 +108,7 @@ class _PageWindowTitleBarState extends ConsumerState { return SliverPadding( padding: EdgeInsets.only( - left: DesktopTools.platform.isMacOS && - hasFullscreen && - hasLeadingOrCanPop - ? 65 - : 0, + left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, ), sliver: SliverAppBar( leading: widget.leading, @@ -149,11 +146,7 @@ class _PageWindowTitleBarState extends ConsumerState { onVerticalDragStart: onDrag, child: Padding( padding: EdgeInsets.only( - left: DesktopTools.platform.isMacOS && - hasFullscreen && - hasLeadingOrCanPop - ? 65 - : 0, + left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, ), child: AppBar( leading: widget.leading, @@ -193,12 +186,12 @@ class WindowTitleBarButtons extends HookConsumerWidget { const type = ThemeType.auto; Future onClose() async { - await DesktopTools.window.close(); + await windowManager.close(); } useEffect(() { if (kIsDesktop) { - DesktopTools.window.isMaximized().then((value) { + windowManager.isMaximized().then((value) { isMaximized.value = value; }); } @@ -235,14 +228,14 @@ class WindowTitleBarButtons extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ MinimizeWindowButton( - onPressed: DesktopTools.window.minimize, + onPressed: windowManager.minimize, colors: colors, ), if (isMaximized.value != true) MaximizeWindowButton( colors: colors, onPressed: () { - DesktopTools.window.maximize(); + windowManager.maximize(); isMaximized.value = true; }, ) @@ -250,7 +243,7 @@ class WindowTitleBarButtons extends HookConsumerWidget { RestoreWindowButton( colors: colors, onPressed: () { - DesktopTools.window.unmaximize(); + windowManager.unmaximize(); isMaximized.value = false; }, ), @@ -270,16 +263,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { children: [ DecoratedMinimizeButton( type: type, - onPressed: DesktopTools.window.minimize, + onPressed: windowManager.minimize, ), DecoratedMaximizeButton( type: type, onPressed: () async { - if (await DesktopTools.window.isMaximized()) { - await DesktopTools.window.unmaximize(); + if (await windowManager.isMaximized()) { + await windowManager.unmaximize(); isMaximized.value = false; } else { - await DesktopTools.window.maximize(); + await windowManager.maximize(); isMaximized.value = true; } }, diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/shared/tracks_view/sections/header/flexible_header.dart index 4a704302..d6e71e8f 100644 --- a/lib/components/shared/tracks_view/sections/header/flexible_header.dart +++ b/lib/components/shared/tracks_view/sections/header/flexible_header.dart @@ -1,7 +1,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; @@ -12,6 +12,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:gap/gap.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; +import 'package:spotube/utils/platform.dart'; class TrackViewFlexHeader extends HookConsumerWidget { const TrackViewFlexHeader({super.key}); @@ -53,7 +54,7 @@ class TrackViewFlexHeader extends HookConsumerWidget { floating: false, pinned: true, expandedHeight: 450, - automaticallyImplyLeading: DesktopTools.platform.isMobile, + automaticallyImplyLeading: kIsMobile, backgroundColor: palette.color, title: isExpanded ? null : Text(props.title, style: headingStyle), flexibleSpace: FlexibleSpaceBar( diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/shared/tracks_view/track_view.dart index eb8f6871..03d628a8 100644 --- a/lib/components/shared/tracks_view/track_view.dart +++ b/lib/components/shared/tracks_view/track_view.dart @@ -1,5 +1,5 @@ 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:sliver_tools/sliver_tools.dart'; @@ -8,6 +8,7 @@ import 'package:spotube/components/shared/page_window_title_bar.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'; +import 'package:spotube/utils/platform.dart'; class TrackView extends HookConsumerWidget { const TrackView({super.key}); @@ -18,7 +19,7 @@ class TrackView extends HookConsumerWidget { final controller = useScrollController(); return Scaffold( - appBar: DesktopTools.platform.isDesktop + appBar: kIsDesktop ? const PageWindowTitleBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, diff --git a/lib/hooks/configurators/use_close_behavior.dart b/lib/hooks/configurators/use_close_behavior.dart index 79b14fa9..3df6a528 100644 --- a/lib/hooks/configurators/use_close_behavior.dart +++ b/lib/hooks/configurators/use_close_behavior.dart @@ -1,29 +1,31 @@ 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'; -// ignore: depend_on_referenced_packages import 'package:local_notifier/local_notifier.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.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); - }; +final closeNotification = !kIsDesktop + ? null + : (LocalNotification( + title: 'Spotube', + body: '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(); + await windowManager.hide(); closeNotification?.show(); } else { exit(0); diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart index 2650b05c..90d062dc 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -7,7 +7,7 @@ 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'; +import 'package:spotube/utils/platform.dart'; final appLinks = AppLinks(); final linkStream = appLinks.allStringLinkStream.asBroadcastStream(); @@ -53,7 +53,7 @@ void useDeepLinking(WidgetRef ref) { StreamSubscription? mediaStream; - if (DesktopTools.platform.isMobile) { + if (kIsMobile) { FlutterSharingIntent.instance.getInitialSharing().then(uriListener); mediaStream = diff --git a/lib/hooks/configurators/use_disable_battery_optimizations.dart b/lib/hooks/configurators/use_disable_battery_optimizations.dart index a9afef45..4aa51b74 100644 --- a/lib/hooks/configurators/use_disable_battery_optimizations.dart +++ b/lib/hooks/configurators/use_disable_battery_optimizations.dart @@ -1,12 +1,12 @@ import 'package:disable_battery_optimization/disable_battery_optimization.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + import 'package:spotube/hooks/utils/use_async_effect.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/utils/platform.dart'; void useDisableBatteryOptimizations() { useAsyncEffect(() async { - if (!DesktopTools.platform.isAndroid || - KVStoreService.askedForBatteryOptimization) return; + if (!kIsAndroid || KVStoreService.askedForBatteryOptimization) return; await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart index db51af14..bcc34042 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -1,17 +1,18 @@ import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart'; +import 'package:spotube/utils/platform.dart'; void useGetStoragePermissions(WidgetRef ref) { final context = useContext(); useAsyncEffect( () async { - if (!DesktopTools.platform.isMobile) return; + if (!kIsMobile) return; final androidInfo = await DeviceInfoPlugin().androidInfo; diff --git a/lib/hooks/configurators/use_init_sys_tray.dart b/lib/hooks/configurators/use_init_sys_tray.dart deleted file mode 100644 index 0bce6727..00000000 --- a/lib/hooks/configurators/use_init_sys_tray.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'dart:io'; - -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/collections/intents.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -void useInitSysTray(WidgetRef ref) { - final context = useContext(); - final systemTray = useRef(null); - - final initializeMenu = useCallback(() async { - systemTray.value?.destroy(); - final playlist = ref.read(proxyPlaylistProvider); - final playlistQueue = ref.read(proxyPlaylistProvider.notifier); - final preferences = ref.read(userPreferencesProvider); - if (!preferences.showSystemTrayIcon) { - await systemTray.value?.destroy(); - systemTray.value = null; - return; - } - final enabled = !playlist.isFetching; - systemTray.value = await DesktopTools.createSystemTrayMenu( - title: DesktopTools.platform.isWindows ? "Spotube" : "", - iconPath: "assets/spotube-logo.png", - windowsIconPath: "assets/spotube-logo.ico", - items: [ - MenuItemLabel( - label: "Show/Hide", - name: "show-hide", - onClicked: (item) async { - if (await DesktopTools.window.isVisible()) { - await DesktopTools.window.hide(); - } else { - await DesktopTools.window.show(); - } - }, - ), - MenuSeparator(), - MenuItemLabel( - label: "Play/Pause", - name: "play-pause", - enabled: enabled, - onClicked: (_) async { - Actions.maybeInvoke( - context, PlayPauseIntent(ref)) ?? - PlayPauseAction().invoke(PlayPauseIntent(ref)); - }, - ), - MenuItemLabel( - label: "Next", - name: "next", - enabled: enabled && (playlist.tracks.length) > 1, - onClicked: (p0) async { - await playlistQueue.next(); - }, - ), - MenuItemLabel( - label: "Previous", - name: "previous", - enabled: enabled && (playlist.tracks.length) > 1, - onClicked: (p0) async { - await playlistQueue.previous(); - }, - ), - MenuSeparator(), - MenuItemLabel( - label: "Quit", - name: "quit", - onClicked: (item) async { - exit(0); - }, - ), - ], - onEvent: (event, tray) async { - if (DesktopTools.platform.isWindows) { - switch (event) { - case SystemTrayEvent.click: - await DesktopTools.window.show(); - break; - case SystemTrayEvent.rightClick: - await tray.popUpContextMenu(); - break; - default: - } - } else { - switch (event) { - case SystemTrayEvent.rightClick: - await DesktopTools.window.show(); - break; - case SystemTrayEvent.click: - await tray.popUpContextMenu(); - break; - default: - } - } - }, - ); - }, [ref]); - - useReassemble(initializeMenu); - - ref.listen( - proxyPlaylistProvider, - (previous, next) { - initializeMenu(); - }, - ); - ref.listen( - userPreferencesProvider.select((s) => s.showSystemTrayIcon), - (previous, next) { - initializeMenu(); - }, - ); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - initializeMenu(); - }); - return () async { - await systemTray.value?.destroy(); - }; - }, [initializeMenu]); -} diff --git a/lib/hooks/configurators/use_window_listener.dart b/lib/hooks/configurators/use_window_listener.dart index b91ad413..5977ea8e 100644 --- a/lib/hooks/configurators/use_window_listener.dart +++ b/lib/hooks/configurators/use_window_listener.dart @@ -1,6 +1,8 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.dart'; class CallbackWindowListener implements WindowListener { final VoidCallback? _onWindowClose; @@ -154,6 +156,8 @@ void useWindowListener({ VoidCallback? onWindowEvent, }) { useEffect(() { + if (!kIsDesktop) return null; + final listener = CallbackWindowListener( onWindowClose: onWindowClose, onWindowFocus: onWindowFocus, @@ -172,9 +176,9 @@ void useWindowListener({ onWindowUndocked: onWindowUndocked, onWindowEvent: onWindowEvent, ); - DesktopTools.window.addListener(listener); + windowManager.addListener(listener); return () { - DesktopTools.window.removeListener(listener); + windowManager.removeListener(listener); }; }, [ onWindowClose, diff --git a/lib/main.dart b/lib/main.dart index 0bb72932..7123b0d0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,11 +4,11 @@ import 'package:device_preview/device_preview.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:local_notifier/local_notifier.dart'; import 'package:media_kit/media_kit.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -19,6 +19,7 @@ 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/provider/tray_manager/tray_manager.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; @@ -31,15 +32,17 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/utils/platform.dart'; import 'package:system_theme/system_theme.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:spotube/hooks/configurators/use_init_sys_tray.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:timezone/data/latest.dart' as tz; +import 'package:window_manager/window_manager.dart'; Future main(List rawArgs) async { final arguments = await startCLI(rawArgs); @@ -55,12 +58,12 @@ Future main(List rawArgs) async { MediaKit.ensureInitialized(); // force High Refresh Rate on some Android devices (like One Plus) - if (DesktopTools.platform.isAndroid) { + if (kIsAndroid) { await FlutterDisplayMode.setHighRefreshRate(); } - if (DesktopTools.platform.isDesktop) { - await DesktopTools.window.setPreventClose(true); + if (kIsDesktop) { + await windowManager.setPreventClose(true); } await SystemTheme.accentColor.load(); @@ -69,7 +72,7 @@ Future main(List rawArgs) async { MetadataGod.initialize(); } - if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) { + if (kIsWindows || kIsLinux) { DiscordRPC.initialize(); } @@ -101,14 +104,10 @@ Future main(List rawArgs) async { path: hiveCacheDir, ); - await DesktopTools.ensureInitialized( - DesktopWindowOptions( - hideTitleBar: true, - title: "Spotube", - backgroundColor: Colors.transparent, - minimumSize: const Size(300, 700), - ), - ); + if (kIsDesktop) { + await localNotifier.setup(appName: "Spotube"); + await WindowManagerTools.initialize(); + } Catcher2( enableLogger: arguments["verbose"], @@ -189,9 +188,9 @@ class SpotubeState extends ConsumerState { ref.listen(playbackServerProvider, (_, __) {}); ref.listen(connectServerProvider, (_, __) {}); ref.listen(connectClientsProvider, (_, __) {}); + ref.listen(trayManagerProvider, (_, __) {}); useDisableBatteryOptimizations(); - useInitSysTray(ref); useDeepLinking(ref); useCloseBehavior(ref); useGetStoragePermissions(ref); @@ -233,9 +232,7 @@ class SpotubeState extends ConsumerState { builder: (context, child) { return DevicePreview.appBuilder( context, - DesktopTools.platform.isDesktop && !DesktopTools.platform.isMacOS - ? DragToResizeArea(child: child!) - : child, + kIsDesktop && !kIsMacOS ? DragToResizeArea(child: child!) : child, ); }, themeMode: themeMode, diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart index dcbd783d..face800e 100644 --- a/lib/models/connect/connect.freezed.dart +++ b/lib/models/connect/connect.freezed.dart @@ -12,7 +12,7 @@ part of 'connect.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); WebSocketLoadEventData _$WebSocketLoadEventDataFromJson( Map json) { diff --git a/lib/models/spotify/home_feed.freezed.dart b/lib/models/spotify/home_feed.freezed.dart index 97c4ffc7..c2bb2aba 100644 --- a/lib/models/spotify/home_feed.freezed.dart +++ b/lib/models/spotify/home_feed.freezed.dart @@ -12,7 +12,7 @@ part of 'home_feed.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); SpotifySectionPlaylist _$SpotifySectionPlaylistFromJson( Map json) { diff --git a/lib/models/spotify/recommendation_seeds.freezed.dart b/lib/models/spotify/recommendation_seeds.freezed.dart index 4cfcce12..adf4aab8 100644 --- a/lib/models/spotify/recommendation_seeds.freezed.dart +++ b/lib/models/spotify/recommendation_seeds.freezed.dart @@ -12,7 +12,7 @@ part of 'recommendation_seeds.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); /// @nodoc mixin _$GeneratePlaylistProviderInput { diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index d80b4513..ca4e7238 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -12,7 +12,7 @@ import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:spotube/utils/platform.dart'; class GenrePlaylistsPage extends HookConsumerWidget { final Category category; @@ -27,7 +27,7 @@ class GenrePlaylistsPage extends HookConsumerWidget { final scrollController = useScrollController(); return Scaffold( - appBar: DesktopTools.platform.isDesktop + appBar: kIsDesktop ? const PageWindowTitleBar( leading: BackButton(color: Colors.white), backgroundColor: Colors.transparent, @@ -53,12 +53,12 @@ class GenrePlaylistsPage extends HookConsumerWidget { controller: scrollController, slivers: [ SliverAppBar( - automaticallyImplyLeading: DesktopTools.platform.isMobile, + automaticallyImplyLeading: kIsMobile, expandedHeight: mediaQuery.mdAndDown ? 200 : 150, title: const Text(""), backgroundColor: Colors.transparent, flexibleSpace: FlexibleSpaceBar( - centerTitle: DesktopTools.platform.isDesktop, + centerTitle: kIsDesktop, title: Text( category.name!, style: Theme.of(context).textTheme.headlineMedium?.copyWith( diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 1e4d4641..6d6f75a9 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; @@ -18,6 +17,7 @@ import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.dart'; class MiniLyricsPage extends HookConsumerWidget { final Size prevSize; @@ -36,9 +36,11 @@ class MiniLyricsPage extends HookConsumerWidget { final showLyrics = useState(true); useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) async { - wasMaximized.value = await DesktopTools.window.isMaximized(); - }); + if (kIsDesktop) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + wasMaximized.value = await windowManager.isMaximized(); + }); + } return null; }, []); @@ -112,11 +114,13 @@ class MiniLyricsPage extends HookConsumerWidget { areaActive.value = true; hoverMode.value = false; - await DesktopTools.window.setSize( - showLyrics.value - ? const Size(400, 500) - : const Size(400, 150), - ); + if (kIsDesktop) { + await windowManager.setSize( + showLyrics.value + ? const Size(400, 500) + : const Size(400, 150), + ); + } }, ), IconButton( @@ -135,33 +139,34 @@ class MiniLyricsPage extends HookConsumerWidget { hoverMode.value = !hoverMode.value; }, ), - FutureBuilder( - future: DesktopTools.window.isAlwaysOnTop(), - builder: (context, snapshot) { - return IconButton( - tooltip: context.l10n.always_on_top, - icon: Icon( - snapshot.data == true - ? SpotubeIcons.pinOn - : SpotubeIcons.pinOff, - ), - style: ButtonStyle( - foregroundColor: snapshot.data == true - ? MaterialStateProperty.all( - theme.colorScheme.primary) - : null, - ), - onPressed: snapshot.data == null - ? null - : () async { - await DesktopTools.window.setAlwaysOnTop( - snapshot.data == true ? false : true, - ); - update(); - }, - ); - }, - ), + if (kIsDesktop) + FutureBuilder( + future: windowManager.isAlwaysOnTop(), + builder: (context, snapshot) { + return IconButton( + tooltip: context.l10n.always_on_top, + icon: Icon( + snapshot.data == true + ? SpotubeIcons.pinOn + : SpotubeIcons.pinOff, + ), + style: ButtonStyle( + foregroundColor: snapshot.data == true + ? MaterialStateProperty.all( + theme.colorScheme.primary) + : null, + ), + onPressed: snapshot.data == null + ? null + : () async { + await windowManager.setAlwaysOnTop( + snapshot.data == true ? false : true, + ); + update(); + }, + ); + }, + ), ], ), ), @@ -243,19 +248,20 @@ class MiniLyricsPage extends HookConsumerWidget { tooltip: context.l10n.exit_mini_player, icon: const Icon(SpotubeIcons.maximize), onPressed: () async { + if (!kIsDesktop) return; + try { - await DesktopTools.window + await windowManager .setMinimumSize(const Size(300, 700)); - await DesktopTools.window.setAlwaysOnTop(false); + await windowManager.setAlwaysOnTop(false); if (wasMaximized.value) { - await DesktopTools.window.maximize(); + await windowManager.maximize(); } else { - await DesktopTools.window.setSize(prevSize); + await windowManager.setSize(prevSize); } - await DesktopTools.window - .setAlignment(Alignment.center); + await windowManager.setAlignment(Alignment.center); if (!kIsLinux) { - await DesktopTools.window.setHasShadow(true); + await windowManager.setHasShadow(true); } await Future.delayed( const Duration(milliseconds: 200)); diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 5ac0689a..f3ed6571 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -21,6 +20,7 @@ import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/utils/platform.dart'; const rootPaths = { "/": 0, @@ -206,7 +206,7 @@ class RootApp extends HookConsumerWidget { ), extendBody: true, drawerScrimColor: Colors.transparent, - endDrawer: DesktopTools.platform.isDesktop + endDrawer: kIsDesktop ? Container( constraints: const BoxConstraints(maxWidth: 800), decoration: BoxDecoration( diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index 4e4408d9..56306868 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -8,6 +7,7 @@ import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/utils/platform.dart'; class SettingsDesktopSection extends HookConsumerWidget { const SettingsDesktopSection({super.key}); @@ -53,7 +53,7 @@ class SettingsDesktopSection extends HookConsumerWidget { value: preferences.systemTitleBar, onChanged: preferencesNotifier.setSystemTitleBar, ), - if (!DesktopTools.platform.isMacOS) + if (!kIsMacOS) SwitchListTile( secondary: const Icon(SpotubeIcons.discord), title: Text(context.l10n.discord_rich_presence), diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index 1f25028e..76ef8e3e 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -1,13 +1,13 @@ import 'package:file_picker/file_picker.dart'; import 'package:file_selector/file_selector.dart'; 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/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; class SettingsDownloadsSection extends HookConsumerWidget { const SettingsDownloadsSection({super.key}); @@ -18,7 +18,7 @@ class SettingsDownloadsSection extends HookConsumerWidget { final preferences = ref.watch(userPreferencesProvider); final pickDownloadLocation = useCallback(() async { - if (DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS) { + if (kIsMobile || kIsMacOS) { final dirStr = await FilePicker.platform.getDirectoryPath( initialDirectory: preferences.downloadLocation, ); diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index d2a75057..d293518d 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,6 +1,5 @@ import 'package:flutter/foundation.dart'; 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/shared/page_window_title_bar.dart'; @@ -14,6 +13,7 @@ import 'package:spotube/pages/settings/sections/downloads.dart'; import 'package:spotube/pages/settings/sections/language_region.dart'; import 'package:spotube/pages/settings/sections/playback.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; class SettingsPage extends HookConsumerWidget { const SettingsPage({super.key}); @@ -45,8 +45,7 @@ class SettingsPage extends HookConsumerWidget { const SettingsAppearanceSection(), const SettingsPlaybackSection(), const SettingsDownloadsSection(), - if (DesktopTools.platform.isDesktop) - const SettingsDesktopSection(), + if (kIsDesktop) const SettingsDesktopSection(), if (!kIsWeb) const SettingsDevelopersSection(), const SettingsAboutSection(), Center( diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index ca8eecfa..f90db54a 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -1,21 +1,19 @@ 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/extensions/artist_simple.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/platform.dart'; class Discord extends ChangeNotifier { final DiscordRPC? discordRPC; final bool isEnabled; Discord(this.isEnabled) - : discordRPC = (DesktopTools.platform.isWindows || - DesktopTools.platform.isLinux) && - isEnabled + : discordRPC = (kIsWindows || kIsLinux) && isEnabled ? DiscordRPC(applicationId: Env.discordAppId) : null { discordRPC?.start(autoRegister: true); diff --git a/lib/provider/tray_manager/tray_manager.dart b/lib/provider/tray_manager/tray_manager.dart new file mode 100644 index 00000000..2145cbef --- /dev/null +++ b/lib/provider/tray_manager/tray_manager.dart @@ -0,0 +1,79 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/tray_manager/tray_menu.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; + +class SystemTrayManager with TrayListener { + final Ref ref; + final bool enabled; + + SystemTrayManager( + this.ref, { + required this.enabled, + }) { + initialize(); + } + + Future initialize() async { + if (!kIsDesktop) return; + + if (enabled) { + await trayManager.setIcon( + kIsWindows + ? 'assets/spotube-logo.ico' + : kIsFlatpak + ? 'com.github.KRTirtho.Spotube.png' + : 'assets/spotube-logo.png', + ); + trayManager.addListener(this); + } else { + await trayManager.destroy(); + } + } + + void dispose() { + trayManager.removeListener(this); + } + + @override + onTrayIconMouseDown() { + if (kIsWindows) { + windowManager.show(); + } else { + trayManager.popUpContextMenu(); + } + } + + @override + onTrayIconRightMouseDown() { + if (!kIsWindows) { + windowManager.show(); + } else { + trayManager.popUpContextMenu(); + } + } +} + +final trayManagerProvider = Provider( + (ref) { + final enabled = ref.watch( + userPreferencesProvider.select((s) => s.showSystemTrayIcon), + ); + + ref.listen(trayMenuProvider, (_, menu) { + if (!enabled || !kIsDesktop) return; + trayManager.setContextMenu(menu); + }); + + final manager = SystemTrayManager( + ref, + enabled: enabled, + ); + + ref.onDispose(manager.dispose); + + return manager; + }, +); diff --git a/lib/provider/tray_manager/tray_menu.dart b/lib/provider/tray_manager/tray_menu.dart new file mode 100644 index 00000000..cb793707 --- /dev/null +++ b/lib/provider/tray_manager/tray_menu.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; + +final audioPlayerLoopMode = StreamProvider((ref) { + return audioPlayer.loopModeStream; +}); + +final audioPlayerShuffleMode = StreamProvider((ref) { + return audioPlayer.shuffledStream; +}); +final audioPlayerPlaying = StreamProvider((ref) { + return audioPlayer.playingStream; +}); + +final trayMenuProvider = Provider((ref) { + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final isPlaybackPlaying = + ref.watch(proxyPlaylistProvider.select((s) => s.activeTrack != null)); + final isLoopOne = + ref.watch(audioPlayerLoopMode).asData?.value == PlaybackLoopMode.one; + final isShuffled = ref.watch(audioPlayerShuffleMode).asData?.value ?? false; + final isPlaying = ref.watch(audioPlayerPlaying).asData?.value ?? false; + + return Menu( + items: [ + MenuItem( + label: "Show/Hide Window", + onClick: (menuItem) async { + if (await windowManager.isVisible()) { + await windowManager.hide(); + } else { + await windowManager.focus(); + await windowManager.show(); + } + }, + ), + MenuItem.separator(), + MenuItem( + label: isPlaying ? "Pause" : "Play", + disabled: !isPlaybackPlaying, + onClick: (menuItem) async { + if (audioPlayer.isPlaying) { + await audioPlayer.pause(); + } else { + await audioPlayer.resume(); + } + }, + ), + MenuItem( + label: "Next", + disabled: !isPlaybackPlaying, + onClick: (menuItem) { + playlistNotifier.next(); + }, + ), + MenuItem( + label: "Previous", + disabled: !isPlaybackPlaying, + onClick: (menuItem) { + playlistNotifier.previous(); + }, + ), + MenuItem.submenu( + label: "Playback", + submenu: Menu( + items: [ + MenuItem( + label: "Repeat", + checked: isLoopOne, + onClick: (menuItem) { + audioPlayer.setLoopMode( + isLoopOne ? PlaybackLoopMode.none : PlaybackLoopMode.one, + ); + }, + ), + MenuItem( + label: "Shuffle", + checked: isShuffled, + onClick: (menuItem) { + audioPlayer.setShuffle(!isShuffled); + }, + ), + MenuItem.separator(), + MenuItem( + label: "Stop", + onClick: (menuItem) { + playlistNotifier.stop(); + }, + ), + ], + ), + ), + MenuItem.separator(), + MenuItem( + label: "Quit", + onClick: (menuItem) { + exit(0); + }, + ), + ], + ); +}); diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index a1e247b2..a537038e 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; @@ -15,6 +14,7 @@ import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; import 'package:path/path.dart' as path; +import 'package:window_manager/window_manager.dart'; class UserPreferencesNotifier extends PersistedStateNotifier { final Ref ref; @@ -103,8 +103,8 @@ class UserPreferencesNotifier extends PersistedStateNotifier { void setSystemTitleBar(bool isSystemTitleBar) { state = state.copyWith(systemTitleBar: isSystemTitleBar); - if (DesktopTools.platform.isDesktop) { - DesktopTools.window.setTitleBarStyle( + if (kIsDesktop) { + windowManager.setTitleBarStyle( isSystemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, ); } @@ -151,8 +151,8 @@ class UserPreferencesNotifier extends PersistedStateNotifier { ); } - if (DesktopTools.platform.isDesktop) { - await DesktopTools.window.setTitleBarStyle( + if (kIsDesktop) { + await windowManager.setTitleBarStyle( state.systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, ); } diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index e35c73b5..67eb18a2 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -62,10 +62,10 @@ class UserPreferences with _$UserPreferences { @Default(false) bool amoledDarkTheme, @Default(true) bool checkUpdate, @Default(false) bool normalizeAudio, - @Default(true) bool showSystemTrayIcon, + @Default(false) bool showSystemTrayIcon, @Default(false) bool skipNonMusic, @Default(false) bool systemTitleBar, - @Default(CloseBehavior.minimizeToTray) CloseBehavior closeBehavior, + @Default(CloseBehavior.close) CloseBehavior closeBehavior, @Default(SpotubeColor(0xFF2196F3, name: "Blue")) @JsonKey( fromJson: UserPreferences._accentColorSchemeFromJson, diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart index a5b076bb..94015d37 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -12,7 +12,7 @@ part of 'user_preferences_state.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); UserPreferences _$UserPreferencesFromJson(Map json) { return _UserPreferences.fromJson(json); @@ -415,10 +415,10 @@ class _$UserPreferencesImpl implements _UserPreferences { this.amoledDarkTheme = false, this.checkUpdate = true, this.normalizeAudio = false, - this.showSystemTrayIcon = true, + this.showSystemTrayIcon = false, this.skipNonMusic = false, this.systemTitleBar = false, - this.closeBehavior = CloseBehavior.minimizeToTray, + this.closeBehavior = CloseBehavior.close, @JsonKey( fromJson: UserPreferences._accentColorSchemeFromJson, toJson: UserPreferences._accentColorSchemeToJson, diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index 8bdd12cc..930b1dd1 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -16,12 +16,12 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, checkUpdate: json['checkUpdate'] as bool? ?? true, normalizeAudio: json['normalizeAudio'] as bool? ?? false, - showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? true, + showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? false, skipNonMusic: json['skipNonMusic'] as bool? ?? false, systemTitleBar: json['systemTitleBar'] as bool? ?? false, closeBehavior: $enumDecodeNullable(_$CloseBehaviorEnumMap, json['closeBehavior']) ?? - CloseBehavior.minimizeToTray, + CloseBehavior.close, accentColorScheme: UserPreferences._accentColorSchemeReadValue( json, 'accentColorScheme') == null diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index a81c6c95..92de192b 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -101,7 +101,7 @@ abstract class AudioPlayerInterface { return _mkPlayer.state.completed; } - Future get isShuffled async { + bool get isShuffled { return _mkPlayer.shuffled; } diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart index 916a983f..e32a0d14 100644 --- a/lib/services/audio_player/custom_player.dart +++ b/lib/services/audio_player/custom_player.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:catcher_2/catcher_2.dart'; import 'package:media_kit/media_kit.dart'; import 'package:flutter_broadcasts/flutter_broadcasts.dart'; @@ -7,6 +6,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:audio_session/audio_session.dart'; // ignore: implementation_imports import 'package:spotube/services/audio_player/playback_state.dart'; +import 'package:spotube/utils/platform.dart'; /// MediaKit [Player] by default doesn't have a state stream. /// This class adds a state stream to the [Player] class. @@ -54,7 +54,7 @@ class CustomPlayer extends Player { PackageInfo.fromPlatform().then((packageInfo) { _packageName = packageInfo.packageName; }); - if (DesktopTools.platform.isAndroid) { + if (kIsAndroid) { _androidAudioManager = AndroidAudioManager(); AudioSession.instance.then((s) async { _androidAudioSessionId = @@ -71,7 +71,7 @@ class CustomPlayer extends Player { } Future notifyAudioSessionUpdate(bool active) async { - if (DesktopTools.platform.isAndroid) { + if (kIsAndroid) { sendBroadcast( BroadcastMessage( name: active diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 338427aa..f42d6c4b 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -1,5 +1,4 @@ import 'package:audio_service/audio_service.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/artist_simple.dart'; @@ -8,6 +7,7 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/utils/platform.dart'; class AudioServices { final MobileAudioService? mobile; @@ -19,9 +19,7 @@ class AudioServices { Ref ref, ProxyPlaylistNotifier playback, ) async { - final mobile = DesktopTools.platform.isMobile || - DesktopTools.platform.isMacOS || - DesktopTools.platform.isLinux + final mobile = kIsMobile || kIsMacOS || kIsLinux ? await AudioService.init( builder: () => MobileAudioService(playback), config: const AudioServiceConfig( @@ -31,9 +29,7 @@ class AudioServices { ), ) : null; - final smtc = DesktopTools.platform.isWindows - ? WindowsAudioService(ref, playback) - : null; + final smtc = kIsWindows ? WindowsAudioService(ref, playback) : null; return AudioServices( mobile, diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index f94ec4ee..ae62a055 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/services/wm_tools/wm_tools.dart'; abstract class KVStoreService { static SharedPreferences? _sharedPreferences; @@ -23,4 +26,21 @@ abstract class KVStoreService { static Future setRecentSearches(List value) async => await sharedPreferences.setStringList('recentSearches', value); + + static WindowSize? get windowSize { + final raw = sharedPreferences.getString('windowSize'); + + if (raw == null) { + return null; + } + return WindowSize.fromJson(jsonDecode(raw)); + } + + static Future setWindowSize(WindowSize value) async => + await sharedPreferences.setString( + 'windowSize', + jsonEncode( + value.toJson(), + ), + ); } diff --git a/lib/services/song_link/song_link.freezed.dart b/lib/services/song_link/song_link.freezed.dart index a8230eeb..0a1af8a9 100644 --- a/lib/services/song_link/song_link.freezed.dart +++ b/lib/services/song_link/song_link.freezed.dart @@ -12,7 +12,7 @@ part of 'song_link.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); SongLink _$SongLinkFromJson(Map json) { return _SongLink.fromJson(json); diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index 75f83125..8444db53 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -163,7 +163,7 @@ class PipedSourcedTrack extends SourcedTrack { final PipedSearchResult(items: searchResults) = await pipedClient.search( query, preference.searchMode == SearchMode.youtube - ? PipedFilter.videos + ? PipedFilter.video : PipedFilter.musicSongs, ); diff --git a/lib/services/wm_tools/wm_tools.dart b/lib/services/wm_tools/wm_tools.dart new file mode 100644 index 00000000..4572a8b4 --- /dev/null +++ b/lib/services/wm_tools/wm_tools.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.dart'; + +class WindowSize { + final double height; + final double width; + final bool maximized; + + WindowSize({ + required this.height, + required this.width, + required this.maximized, + }); + + factory WindowSize.fromJson(Map json) => WindowSize( + height: json["height"], + width: json["width"], + maximized: json["maximized"], + ); + + Map toJson() => { + "height": height, + "width": width, + "maximized": maximized, + }; +} + +class WindowManagerTools with WidgetsBindingObserver { + static WindowManagerTools? _instance; + static WindowManagerTools get instance => _instance!; + + WindowManagerTools._(); + + static Future initialize() async { + await windowManager.ensureInitialized(); + _instance = WindowManagerTools._(); + WidgetsBinding.instance.addObserver(instance); + + await windowManager.waitUntilReadyToShow( + const WindowOptions( + title: "Spotube", + backgroundColor: Colors.transparent, + minimumSize: Size(300, 700), + titleBarStyle: TitleBarStyle.hidden, + ), + () async { + final savedSize = KVStoreService.windowSize; + await windowManager.setResizable(true); + if (savedSize?.maximized == true && + !(await windowManager.isMaximized())) { + await windowManager.maximize(); + } else if (savedSize != null) { + await windowManager.setSize(Size(savedSize.width, savedSize.height)); + } + + await windowManager.focus(); + await windowManager.show(); + }, + ); + } + + Size? _prevSize; + + @override + void didChangeMetrics() async { + super.didChangeMetrics(); + if (kIsMobile) return; + final size = await windowManager.getSize(); + final windowSameDimension = + _prevSize?.width == size.width && _prevSize?.height == size.height; + + if (windowSameDimension || _prevSize == null) { + _prevSize = size; + return; + } + final isMaximized = await windowManager.isMaximized(); + await KVStoreService.setWindowSize( + WindowSize( + height: size.height, + width: size.width, + maximized: isMaximized, + ), + ); + _prevSize = size; + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index c69c17c0..6dfdd740 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -14,7 +14,7 @@ #include #include #include -#include +#include #include #include #include @@ -44,9 +44,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) system_theme_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin"); system_theme_plugin_register_with_registrar(system_theme_registrar); - g_autoptr(FlPluginRegistrar) system_tray_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "SystemTrayPlugin"); - system_tray_plugin_register_with_registrar(system_tray_registrar); + g_autoptr(FlPluginRegistrar) tray_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); + tray_manager_plugin_register_with_registrar(tray_manager_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a4487f4d..93ffd3e9 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -11,7 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_linux screen_retriever system_theme - system_tray + tray_manager url_launcher_linux window_manager window_size diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a9f6650f..84f39341 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -21,7 +21,7 @@ import screen_retriever import shared_preferences_foundation import sqflite import system_theme -import system_tray +import tray_manager import url_launcher_macos import window_manager import window_size @@ -37,13 +37,13 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) - FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) - SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) + TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) WindowSizePlugin.register(with: registry.registrar(forPlugin: "WindowSizePlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 317de385..c1cf630c 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -44,7 +44,7 @@ PODS: - FMDB (>= 2.7.5) - system_theme (0.0.1): - FlutterMacOS - - system_tray (0.0.1): + - tray_manager (0.0.1): - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS @@ -73,7 +73,7 @@ DEPENDENCIES: - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`) - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) - - system_tray (from `Flutter/ephemeral/.symlinks/plugins/system_tray/macos`) + - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) @@ -122,8 +122,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos system_theme: :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos - system_tray: - :path: Flutter/ephemeral/.symlinks/plugins/system_tray/macos + tray_manager: + :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos window_manager: @@ -132,11 +132,11 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: - app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 + app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 - device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea @@ -147,13 +147,13 @@ SPEC CHECKSUMS: media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c - package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc - system_tray: e53c972838c69589ff2e77d6d3abfd71332f9e5d + tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 diff --git a/pubspec.lock b/pubspec.lock index 8d19f604..1532bcf7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "6.4.1" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" url: "https://pub.dev" source: hosted - version: "0.11.2" + version: "0.11.3" ansicolor: dependency: transitive description: @@ -37,90 +37,26 @@ packages: dependency: "direct main" description: name: app_links - sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" + sha256: "42dc15aecf2618ace4ffb74a2e58a50e45cd1b9f2c17c8f0cafe4c297f08c815" url: "https://pub.dev" source: hosted - version: "3.5.0" - app_package_maker: - dependency: transitive - description: - name: app_package_maker - sha256: "0dc1949e09a60ec2d0b79b43c5237734e6d03f7a022919bdfe1b4789f4c3bfb0" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_aab: - dependency: transitive - description: - name: app_package_maker_aab - sha256: "44810e77dff3b3b54011270b01a42876e838766d6e85c98f9a33bfe61db51651" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_apk: - dependency: transitive - description: - name: app_package_maker_apk - sha256: "974e639cda26c2e18fffaba18f88797523731f5b5457056b25e92243c9191f61" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_deb: - dependency: transitive - description: - name: app_package_maker_deb - sha256: dcd4047cb67648e53afd61079a8baa3c8ea383668f068e3ce8da841f3728eb29 - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_dmg: - dependency: transitive - description: - name: app_package_maker_dmg - sha256: e0410a51304f3fff3e3850696c8e56f53f71c990e097f1c325126ebe90d242c4 - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_exe: - dependency: transitive - description: - name: app_package_maker_exe - sha256: "07e3899a3ae12e8b6cd80efc7281ccca6c9050d2810e0fdc0e7e614cf4bd8a02" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_ipa: - dependency: transitive - description: - name: app_package_maker_ipa - sha256: "1a11498506ba975d02a4715650701981a382a2161c81481911517b50b378cd65" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_zip: - dependency: transitive - description: - name: app_package_maker_zip - sha256: cef07a47c589036a4762fdc9e61b9022f0a2a2a9f69538109a0a952a7e668306 - url: "https://pub.dev" - source: hosted - version: "0.0.9" + version: "4.0.1" archive: dependency: transitive description: name: archive - sha256: ca12e6c9ac022f33fd89128e7007fb5e97ab6e814d4fa05dd8d4f2db1e3c69cb + sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" url: "https://pub.dev" source: hosted - version: "3.4.5" + version: "3.4.10" args: dependency: "direct main" description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.0" async: dependency: "direct main" description: @@ -133,18 +69,18 @@ packages: dependency: "direct main" description: name: audio_service - sha256: a4d989f1225ea9621898d60f23236dcbfc04876fa316086c23c5c4af075dbac4 + sha256: "4547c312a94f9cb2c48b60823fb190767cbd63454a83c73049384d5d3cba4650" url: "https://pub.dev" source: hosted - version: "0.18.12" + version: "0.18.13" audio_service_mpris: dependency: "direct main" description: name: audio_service_mpris - sha256: "31be5de2db0c71b217157afce1974ac6d0ad329bd91deb1f19ad094d29340d8e" + sha256: a8d1583f9143d17b2facc994a99bd1ea257cec43adcb8d7349458555c62b570f url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.3" audio_service_platform_interface: dependency: transitive description: @@ -157,18 +93,18 @@ packages: dependency: transitive description: name: audio_service_web - sha256: "523e64ddc914c714d53eec2da85bba1074f08cf26c786d4efb322de510815ea7" + sha256: "9d7d5ae5f98a5727f2580fad73062f2484f400eef6cef42919413268e62a363e" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.2" audio_session: dependency: "direct main" description: name: audio_session - sha256: "6fdf255ed3af86535c96452c33ecff1245990bb25a605bfb1958661ccc3d467f" + sha256: a49af9981eec5d7cd73b37bacb6ee73f8143a6a9f9bd5b6021e6c346b9b6cf4e url: "https://pub.dev" source: hosted - version: "0.1.18" + version: "0.1.19" auto_size_text: dependency: "direct main" description: @@ -261,18 +197,18 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.1" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: d912852cce27c9e80a93603db721c267716894462e7033165178b91138587972 + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.2" build_runner: dependency: "direct dev" description: @@ -285,10 +221,10 @@ packages: dependency: transitive description: name: build_runner_core - sha256: "6d6ee4276b1c5f34f21fdf39425202712d2be82019983d52f351c94aafbc2c41" + sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" url: "https://pub.dev" source: hosted - version: "7.2.10" + version: "7.3.0" built_collection: dependency: transitive description: @@ -301,18 +237,18 @@ packages: dependency: transitive description: name: built_value - sha256: ff627b645b28fb8bdb69e645f910c2458fd6b65f6585c3a53e0626024897dedf + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.6.2" + version: "8.9.2" buttons_tabbar: dependency: "direct main" description: name: buttons_tabbar - sha256: "781128180f3e76cf93c093183f10395c664983dbee20bc4da2025be70085c2da" + sha256: "3f0969c26574ef15c0c9ff1dee42c3c4b0d3563d2c8607804372490fb8b76896" url: "https://pub.dev" source: hosted - version: "1.3.7+1" + version: "1.3.8" cached_network_image: dependency: "direct main" description: @@ -341,10 +277,10 @@ packages: dependency: "direct main" description: name: catcher_2 - sha256: "73e251057b1b59442d58b5109d26401cfed850df21da44b028338d4f85f69105" + sha256: "3c8f6cedc8c5eab61192830096d4f303900a5d0bddbf96a07ff9f7a8d5ff8fcd" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.2.4" change_case: dependency: transitive description: @@ -381,10 +317,10 @@ packages: dependency: transitive description: name: cli_util - sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.1" clock: dependency: transitive description: @@ -397,10 +333,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "315a598c7fbe77f22de1c9da7cfd6fd21816312f16ffa124453b4fc679e540f1" + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 url: "https://pub.dev" source: hosted - version: "4.6.0" + version: "4.10.0" collection: dependency: "direct main" description: @@ -429,10 +365,10 @@ packages: dependency: transitive description: name: cross_file - sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb + sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" url: "https://pub.dev" source: hosted - version: "0.3.3+5" + version: "0.3.4+1" crypto: dependency: "direct main" description: @@ -449,14 +385,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d - url: "https://pub.dev" - source: hosted - version: "1.0.6" curved_navigation_bar: dependency: "direct main" description: @@ -469,26 +397,26 @@ packages: dependency: "direct dev" description: name: custom_lint - sha256: "22bd87a362f433ba6aae127a7bac2838645270737f3721b180916d7c5946cb5d" + sha256: "7c0aec12df22f9082146c354692056677f1e70bc43471644d1fdb36c6fdda799" url: "https://pub.dev" source: hosted - version: "0.5.11" + version: "0.6.4" custom_lint_builder: dependency: transitive description: name: custom_lint_builder - sha256: "0d48e002438950f9582e574ef806b2bea5719d8d14c0f9f754fbad729bcf3b19" + sha256: d7dc41e709dde223806660268678be7993559e523eb3164e2a1425fd6f7615a9 url: "https://pub.dev" source: hosted - version: "0.5.14" + version: "0.6.4" custom_lint_core: dependency: transitive description: name: custom_lint_core - sha256: "2952837953022de610dacb464f045594854ced6506ac7f76af28d4a6490e189b" + sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 url: "https://pub.dev" source: hosted - version: "0.5.14" + version: "0.6.3" dart_des: dependency: transitive description: @@ -506,14 +434,22 @@ packages: url: "https://github.com/Tommypop2/dart_discord_rpc.git" source: git version: "0.0.3" + dart_mappable: + dependency: transitive + description: + name: dart_mappable + sha256: "47269caf2060533c29b823ff7fa9706502355ffcb61e7f2a374e3a0fb2f2c3f0" + url: "https://pub.dev" + source: hosted + version: "4.2.2" dart_style: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.6" dartx: dependency: transitive description: @@ -542,10 +478,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" + sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91 url: "https://pub.dev" source: hosted - version: "9.1.2" + version: "10.1.0" device_info_plus_platform_interface: dependency: transitive description: @@ -566,18 +502,18 @@ packages: dependency: "direct main" description: name: dio - sha256: "49af28382aefc53562459104f64d16b9dfd1e8ef68c862d5af436cc8356ce5a8" + sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5" url: "https://pub.dev" source: hosted - version: "5.4.1" + version: "5.4.3+1" disable_battery_optimization: dependency: "direct main" description: name: disable_battery_optimization - sha256: b3441975ab2a3ab0c19ed78e909a88d245ce689d43d17f9b23582b1ed41c047b + sha256: "6b2ba802f984af141faf1b6b5fb956d5ef01f9cd555597c35b9cc335a03185ba" url: "https://pub.dev" source: hosted - version: "1.1.0+1" + version: "1.1.1" dots_indicator: dependency: transitive description: @@ -607,18 +543,26 @@ packages: dependency: "direct main" description: name: envied - sha256: "60d3f5606c7b35bc6ef493e650d916b34351d8af2e58b7ac45881ba59dfcf039" + sha256: bbff9c76120e4dc5e2e36a46690cf0a26feb65e7765633f4e8d916bcd173a450 url: "https://pub.dev" source: hosted - version: "0.3.0+3" + version: "0.5.4+1" envied_generator: dependency: "direct dev" description: name: envied_generator - sha256: dfdbe5dc52863e54c036a4c4042afbdf1bd528cb4c1e638ecba26228ba72e9e5 + sha256: "517b70de08d13dcd40e97b4e5347e216a0b1c75c99e704f3c85c0474a392d14a" url: "https://pub.dev" source: hosted - version: "0.3.0+3" + version: "0.5.4+1" + equatable: + dependency: transitive + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -631,10 +575,10 @@ packages: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" file: dependency: transitive description: @@ -647,34 +591,34 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" + sha256: d1d0ac3966b36dc3e66eeefb40280c17feb87fa2099c6e22e6a1fc959327bd03 url: "https://pub.dev" source: hosted - version: "6.1.1" + version: "8.0.0+1" file_selector: dependency: "direct main" description: name: file_selector - sha256: "84eaf3e034d647859167d1f01cfe7b6352488f34c1b4932635012b202014c25b" + sha256: "5019692b593455127794d5718304ff1ae15447dea286cdda9f0db2a796a1b828" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.3" file_selector_android: dependency: transitive description: name: file_selector_android - sha256: d41e165d6f798ca941d536e5dc93494d50e78c571c28ad60cfe0b0fefeb9f1e7 + sha256: "1cd66575f063b689e041aec836905ba7be18d76c9f0634d0d75daec825f67095" url: "https://pub.dev" source: hosted - version: "0.5.0+3" + version: "0.5.0+7" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: b3fbdda64aa2e335df6e111f6b0f1bb968402ed81d2dd1fa4274267999aa32c2 + sha256: "0a1196a9c5795858aa315332da2fb5c4bcfdcb312d8a4e27651f765b87904431" url: "https://pub.dev" source: hosted - version: "0.5.1+6" + version: "0.5.1+9" file_selector_linux: dependency: transitive description: @@ -687,26 +631,26 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: "182c3f8350cee659f7b115e956047ee3dc672a96665883a545e81581b9a82c72" + sha256: b15c3da8bd4908b9918111fa486903f5808e388b8d1c559949f584725a6594d6 url: "https://pub.dev" source: hosted - version: "0.9.3+2" + version: "0.9.3+3" file_selector_platform_interface: dependency: transitive description: name: file_selector_platform_interface - sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262" + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "2.6.2" file_selector_web: dependency: transitive description: name: file_selector_web - sha256: dc6622c4d66cb1bee623ddcc029036603c6cc45c85e4a775bb06008d61c809c1 + sha256: "619e431b224711a3869e30dbd7d516f5f5a4f04b265013a50912f39e1abc88c8" url: "https://pub.dev" source: hosted - version: "0.9.2+1" + version: "0.9.4+1" file_selector_windows: dependency: transitive description: @@ -727,31 +671,15 @@ packages: dependency: "direct main" description: name: fluentui_system_icons - sha256: "7637cab80bd8d1ba762144cd85df79a7318c12ed5a66d166a9e4acbf24a4c412" + sha256: "1c860f10a0e74c5788ff8a650ae6074d9a544463ae269714f1044b32df52b978" url: "https://pub.dev" source: hosted - version: "1.1.214" + version: "1.1.234" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_app_builder: - dependency: transitive - description: - name: flutter_app_builder - sha256: "9e5527919f62424f0fafaa3e8dfda8469caf63e465862e9866a0d60a37c00fcf" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - flutter_app_packager: - dependency: transitive - description: - name: flutter_app_packager - sha256: b5bfb7113b49710c004c5f1ab6f08ac121418540d49e14825dd75e99810fa695 - url: "https://pub.dev" - source: hosted - version: "0.0.9" flutter_broadcasts: dependency: "direct main" description: @@ -768,15 +696,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" - flutter_desktop_tools: - dependency: "direct main" - description: - path: "." - ref: "1f0bec3283626dcbd8ee2f54e238d096d8dea50e" - resolved-ref: "1f0bec3283626dcbd8ee2f54e238d096d8dea50e" - url: "https://github.com/KRTirtho/flutter_desktop_tools.git" - source: git - version: "0.0.1" flutter_displaymode: dependency: "direct main" description: @@ -785,14 +704,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" - flutter_distributor: - dependency: "direct dev" - description: - name: flutter_distributor - sha256: "50d56df265e97396427ec42cc02374b72d08c71b3442d662b97fc089bd1705ea" - url: "https://pub.dev" - source: hosted - version: "0.0.2" flutter_driver: dependency: transitive description: flutter @@ -890,10 +801,10 @@ packages: dependency: transitive description: name: flutter_keyboard_visibility - sha256: "4983655c26ab5b959252ee204c2fffa4afeb4413cd030455194ec0caa3b8e7cb" + sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8" url: "https://pub.dev" source: hosted - version: "5.4.1" + version: "6.0.0" flutter_keyboard_visibility_linux: dependency: transitive description: @@ -946,10 +857,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" flutter_localizations: dependency: "direct main" description: flutter @@ -959,42 +870,42 @@ packages: dependency: transitive description: name: flutter_mailer - sha256: a935e9caa842877e8ed56109afb75b86e6488edbcd4696a5ac02b327a48fcd8a + sha256: "4fffaa35e911ff5ec2e5a4ebbca62c372e99a154eb3bb2c0bf79f09adf6ecf4c" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" flutter_native_splash: dependency: "direct main" description: name: flutter_native_splash - sha256: "558f10070f03ee71f850a78f7136ab239a67636a294a44a06b6b7345178edb1e" + sha256: edf39bcf4d74aca1eb2c1e43c3e445fd9f494013df7f0da752fefe72020eedc0 url: "https://pub.dev" source: hosted - version: "2.3.10" + version: "2.4.0" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c + sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f" url: "https://pub.dev" source: hosted - version: "2.0.16" + version: "2.0.19" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: "4bce556b7ecbfea26109638d5237684538d4abc509d253e6c5c4c5733b360098" + sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d" url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.5.1" flutter_rust_bridge: dependency: transitive description: name: flutter_rust_bridge - sha256: e12415c3bce49bcbc3fed383f0ea41ad7d828f6cf0eccba0588ffa5a812fe522 + sha256: "02720226035257ad0b571c1256f43df3e1556a499f6bcb004849a0faaa0e87f0" url: "https://pub.dev" source: hosted - version: "1.82.1" + version: "1.82.6" flutter_secure_storage: dependency: "direct main" description: @@ -1047,10 +958,10 @@ packages: dependency: "direct main" description: name: flutter_sharing_intent - sha256: "6eb896e6523b735e8230eeb206fd3b9f220f11ce879c2400a90b443147036ff9" + sha256: "785ffc391822641457f930eb477c91c2f598a888f50b8fbb40d481ee01c7e719" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" flutter_svg: dependency: "direct main" description: @@ -1073,10 +984,10 @@ packages: dependency: transitive description: name: fluttertoast - sha256: "474f7d506230897a3cd28c965ec21c5328ae5605fc9c400cd330e9e9d6ac175c" + sha256: "81b68579e23fcbcada2db3d50302813d2371664afe6165bc78148050ab94bf66" url: "https://pub.dev" source: hosted - version: "8.2.2" + version: "8.2.5" form_validator: dependency: "direct main" description: @@ -1089,10 +1000,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: "6c5031daae12c7072b3a87eff98983076434b4889ef2a44384d0cae3f82372ba" + sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1 url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.5.2" freezed_annotation: dependency: "direct main" description: @@ -1105,10 +1016,10 @@ packages: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -1150,10 +1061,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: f0b8d115a13ecf827013ec9fc883390ccc0e87a96ed5347a3114cac177ef18e8 + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.2.1" graphs: dependency: transitive description: @@ -1206,10 +1117,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: "758b07eba336e3cbacbd81dba481f2228a14102083fdde07045e8514e8054c49" + sha256: "45b2030a18bcd6dbd680c2c91bc3b33e3fe7c323e3acb5ecec93a613e2fbaa8a" url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.5.1" hotreloader: dependency: transitive description: @@ -1270,42 +1181,42 @@ packages: dependency: transitive description: name: image - sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" + sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.1.7" image_picker: dependency: "direct main" description: name: image_picker - sha256: "7d7f2768df2a8b0a3cefa5ef4f84636121987d403130e70b17ef7e2cf650ba84" + sha256: fe9ee64ccb8d599a5dfb0e21cc6652232c610bcf667af4e79b9eb175cc30a7a5 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "47da2161c2e9f8f8a9cbbd89d466d174333fbdd769aeed848912e0b16d9cb369" + sha256: "8e75431a62b7feb4fd55cb4a5c6f0ac4564460ec5dc09f9c4a0d50a5ce7c4cb9" url: "https://pub.dev" source: hosted - version: "0.8.8" + version: "0.8.10" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "50bc9ae6a77eea3a8b11af5eb6c661eeb858fdd2f734c2a4fd17086922347ef7" + sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.4" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: c5538cacefacac733c724be7484377923b476216ad1ead35a0d2eadcdc0fc497 + sha256: f4a6f62be96d6fd268f32a6bf8ef444cd8e3fff64d16923c6e6fe55e0c84a761 url: "https://pub.dev" source: hosted - version: "0.8.8+2" + version: "0.8.10" image_picker_linux: dependency: transitive description: @@ -1326,10 +1237,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: ed9b00e63977c93b0d2d2b343685bed9c324534ba5abafbb3dfbd6a780b1b514 + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" url: "https://pub.dev" source: hosted - version: "2.9.1" + version: "2.10.0" image_picker_windows: dependency: transitive description: @@ -1355,10 +1266,10 @@ packages: dependency: "direct main" description: name: introduction_screen - sha256: ef5a5479a8e06a84b9a7eff16c698b9b82f70cd1b6203b264bc3686f9bfb77e2 + sha256: "325f26e86fa3c3e86e6ab2bbc1fda860c9e6eae5ff29166fc2a3cab8f710d5b5" url: "https://pub.dev" source: hosted - version: "3.1.11" + version: "3.1.14" io: dependency: transitive description: @@ -1432,21 +1343,21 @@ packages: source: hosted version: "3.0.0" local_notifier: - dependency: transitive + dependency: "direct main" description: name: local_notifier - sha256: cc855aa6362c8840e3d3b35b1c3b058a3a8becdb2b03d5a9aa3f3a1e861f0a03 + sha256: f6cfc933c6fbc961f4e52b5c880f68e41b2d3cd29aad557cc654fd211093a025 url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.1.6" logger: dependency: "direct main" description: name: logger - sha256: ba3bc83117b2b49bdd723c0ea7848e8285a0fbc597ba09203b20d329d020c24a + sha256: "8c94b8c219e7e50194efc8771cd0e9f10807d8d3e219af473d89b06cc2ee4e04" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.2.0" logging: dependency: transitive description: @@ -1467,10 +1378,10 @@ packages: dependency: transitive description: name: mailer - sha256: "57f6dd1496699999a7bfd0aa6be0645384f477f4823e16d4321c40a434346382" + sha256: d25d89555c1031abacb448f07b801d7c01b4c21d4558e944b12b64394c84a3cb url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.1.0" matcher: dependency: transitive description: @@ -1491,26 +1402,26 @@ packages: dependency: "direct main" description: name: media_kit - sha256: "92c7f59e075d74471b31e703f81ccc1d7102739ebcce945b30a6417fa2f751d5" + sha256: "3289062540e3b8b9746e5c50d95bd78a9289826b7227e253dff806d002b9e67a" url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.1.10+1" media_kit_libs_android_audio: dependency: transitive description: name: media_kit_libs_android_audio - sha256: "1d9ba8814e58a12ae69014884abf96893d38e444abfb806ff38896dfe089dd15" + sha256: "3d2df5c09d3f3ff7c55b53bf955e46712f76483e77562a5a017439a3ea85ce88" url: "https://pub.dev" source: hosted - version: "1.3.5" + version: "1.3.6" media_kit_libs_audio: dependency: "direct main" description: name: media_kit_libs_audio - sha256: "9ccf9019edc220b8d0bfa1f53a91aa2cf2506ac860b4d5774c9d6bbdd49796ca" + sha256: f3f91df69848005363b3ae0ef7971a90edbd80a9365195684ef26c9a6ac8833f url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" media_kit_libs_ios_audio: dependency: transitive description: @@ -1551,6 +1462,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" meta: dependency: transitive description: @@ -1571,10 +1490,10 @@ packages: dependency: "direct main" description: name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.5" nested: dependency: transitive description: @@ -1611,10 +1530,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "6ff267fcd9d48cb61c8df74a82680e8b82e940231bb5f68356672fde0397334a" + sha256: cb44f49b6e690fa766f023d5b22cac6b9affe741dd792b6ac7ad4fabe0d7b097 url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "6.0.0" package_info_plus_platform_interface: dependency: transitive description: @@ -1659,26 +1578,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.3" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" + sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.4" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" path_provider_linux: dependency: transitive description: @@ -1691,10 +1610,10 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_windows: dependency: transitive description: @@ -1707,42 +1626,50 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.3.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: ace7d15a3d1a4a0b91c041d01e5405df221edb9de9116525efc773c74e6fc790 + sha256: "1acac6bae58144b442f11e66621c062aead9c99841093c38f5bcdcc24c1c3474" url: "https://pub.dev" source: hosted - version: "11.0.5" + version: "12.0.5" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" + sha256: e9ad66020b89ff1b63908f247c2c6f931c6e62699b756ef8b3c4569350cd8662 url: "https://pub.dev" source: hosted - version: "9.1.4" + version: "9.4.4" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: f2343e9fa9c22ae4fd92d4732755bfe452214e7189afcc097380950cf567b4b2 + sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" url: "https://pub.dev" source: hosted - version: "3.11.5" + version: "4.2.1" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.2.1" petitparser: dependency: transitive description: @@ -1754,11 +1681,10 @@ packages: piped_client: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "64631732eefe3d93889756dc2e4ff5c8523ed763" - url: "https://github.com/KRTirtho/piped_client.git" - source: git + name: piped_client + sha256: "87b04b2ebf4e008cfbb0ac85e9920ab3741f5aa697be2dd44919658a3297a4bc" + url: "https://pub.dev" + source: hosted version: "0.1.1" platform: dependency: transitive @@ -1772,18 +1698,18 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.6" + version: "2.1.8" pointycastle: dependency: transitive description: name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + sha256: "70fe966348fe08c34bf929582f1d8247d9d9408130723206472b4687227e4333" url: "https://pub.dev" source: hosted - version: "3.7.3" + version: "3.8.0" pool: dependency: transitive description: @@ -1812,18 +1738,18 @@ packages: dependency: transitive description: name: provider - sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c url: "https://pub.dev" source: hosted - version: "6.0.5" + version: "6.1.2" pub_api_client: dependency: "direct main" description: name: pub_api_client - sha256: d456816ef5142906a22dc56e37be6bef6cb0276f0a26c11d1f7d277868202e71 + sha256: cc3d2c93df3823553de6a3e7d3ac09a3f43f8c271af4f43c2795266090ac9625 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" pub_semver: dependency: transitive description: @@ -1852,10 +1778,10 @@ packages: dependency: transitive description: name: puppeteer - sha256: eedeaae6ec5d2e54f9ae22ab4d6b3dda2e8791c356cc783046d06c287ffe11d8 + sha256: "6833edca01b1e9dcdd9a6e41bad84b706dfba4366d095c4edff64b00c02ac472" url: "https://pub.dev" source: hosted - version: "3.6.0" + version: "3.8.0" quiver: dependency: transitive description: @@ -1864,30 +1790,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" riverpod: dependency: transitive description: name: riverpod - sha256: "548e2192eb7aeb826eb89387f814edb76594f3363e2c0bb99dd733d795ba3589" + sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.1" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: d72d7096964baf288b55619fe48100001fc4564ab7923ed0a7f5c7650e03c0d6 + sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f" url: "https://pub.dev" source: hosted - version: "0.3.4" + version: "0.5.1" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "70198738c3047ae4f6517ef1a2011a8514a980a52576c7f629a3a08810319a02" + sha256: "3c67c14ccd16f0c9d53e35ef70d06cd9d072e2fb14557326886bbde903b230a5" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.3.10" rxdart: dependency: transitive description: @@ -1933,34 +1867,34 @@ packages: dependency: transitive description: name: sentry - sha256: "39c23342fc96105da449914f7774139a17a0ca8a4e70d9ad5200171f7e47d6ba" + sha256: "19a267774906ca3a3c4677fc7e9582ea9da79ae9a28f84bbe4885dac2c269b70" url: "https://pub.dev" source: hosted - version: "7.9.0" + version: "7.20.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.5" shared_preferences_linux: dependency: transitive description: @@ -1973,18 +1907,18 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" shared_preferences_windows: dependency: transitive description: @@ -2025,22 +1959,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" sidebarx: dependency: "direct main" description: name: sidebarx - sha256: "7042d64844b8e64ca5c17e70d89b49df35b54a26c015b90000da9741eab70bc0" + sha256: abe39d6db237fb8e25c600e8039ffab80fa7fe71acab03e9c378c31f912d2766 url: "https://pub.dev" source: hosted - version: "0.16.3" + version: "0.17.1" simple_icons: dependency: "direct main" description: name: simple_icons - sha256: "8aa6832dc7a263a3213e40ecbf1328a392308c809d534a3b860693625890483b" + sha256: "30067d70a9d72923fbc80e142e17fa46085dfa970e66bc4bede3be4819d05901" url: "https://pub.dev" source: hosted - version: "7.10.0" + version: "10.1.3" skeleton_text: dependency: "direct main" description: @@ -2053,10 +1995,10 @@ packages: dependency: "direct main" description: name: skeletonizer - sha256: ff4c36e826efd5288d7a84e7619a6e9be8185d3064cecf101a9133762f3b401b + sha256: "9a3ae2f4ee4349bdbed3292d04586a1315a44745d2c454684f82f0c46dbeabf9" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "1.1.1" sky_engine: dependency: transitive description: flutter @@ -2074,18 +2016,18 @@ packages: dependency: "direct main" description: name: smtc_windows - sha256: aba2bad5ddfaf595496db04df3d9fdb54fb128fc1f39c8f024945a67455388fe + sha256: "799bbe0f8e4436da852c5dcc0be482c97b8ae0f504f65c6b750cd239b4835aa0" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.2" source_gen: dependency: transitive description: name: source_gen - sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" source_helper: dependency: transitive description: @@ -2106,26 +2048,34 @@ packages: dependency: "direct main" description: name: spotify - sha256: "2308a84511c18ec1e72515a57e28abb1467389549d571c460732b4538c2e34de" + sha256: "50bd5a07b580ee441d0b4d81227185ada768332c353671aa7555ea47cc68eb9e" url: "https://pub.dev" source: hosted - version: "0.13.3" + version: "0.13.5" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + url: "https://pub.dev" + source: hosted + version: "7.0.0" sqflite: dependency: transitive description: name: sqflite - sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + sha256: "5ce2e1a15e822c3b4bfb5400455775e421da7098eed8adc8f26298ada7c9308c" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" + sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.4" stack_trace: dependency: transitive description: @@ -2138,10 +2088,10 @@ packages: dependency: transitive description: name: state_notifier - sha256: "8fe42610f179b843b12371e40db58c9444f8757f8b69d181c97e50787caed289" + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb url: "https://pub.dev" source: hosted - version: "0.7.2+1" + version: "1.0.0" stream_channel: dependency: transitive description: @@ -2186,10 +2136,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.0+1" system_theme: dependency: "direct main" description: @@ -2206,14 +2156,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" - system_tray: - dependency: "direct overridden" - description: - name: system_tray - sha256: "087edb877f22f286d82d42f330fa640138c192e98aa9d20c2b83aa4e406bb432" - url: "https://pub.dev" - source: hosted - version: "2.0.2" term_glyph: dependency: transitive description: @@ -2234,10 +2176,10 @@ packages: dependency: transitive description: name: time - sha256: "83427e11d9072e038364a5e4da559e85869b227cf699a541be0da74f14140124" + sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" timezone: dependency: "direct main" description: @@ -2262,6 +2204,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + tray_manager: + dependency: "direct main" + description: + name: tray_manager + sha256: e0ac9a88b2700f366b8629b97e8663b6ef450a2f169560a685dc167bfe9c9c29 + url: "https://pub.dev" + source: hosted + version: "0.2.2" tuple: dependency: transitive description: @@ -2270,6 +2220,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + type_plus: + dependency: transitive + description: + name: type_plus + sha256: d5d1019471f0d38b91603adb9b5fd4ce7ab903c879d2fbf1a3f80a630a03fcc9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" typed_data: dependency: transitive description: @@ -2314,74 +2272,74 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" + sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" url: "https://pub.dev" source: hosted - version: "6.1.14" + version: "6.2.6" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 + sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.3.1" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" + sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.2.5" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.1.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "3.1.0" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.3.1" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.1.1" uuid: dependency: "direct main" description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.4.0" vector_math: dependency: transitive description: @@ -2434,18 +2392,18 @@ packages: dependency: transitive description: name: web - sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad" + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.5.1" web_socket_channel: dependency: "direct main" description: name: web_socket_channel - sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.4.5" webdriver: dependency: transitive description: @@ -2466,26 +2424,26 @@ packages: dependency: transitive description: name: win32 - sha256: "9e82a402b7f3d518fb9c02d0e9ae45952df31b9bf34d77baf19da2de03fc2aaa" + sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" url: "https://pub.dev" source: hosted - version: "5.0.7" + version: "5.4.0" win32_registry: dependency: "direct main" description: name: win32_registry - sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" + sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" window_manager: dependency: "direct main" description: name: window_manager - sha256: "6ee795be9124f90660ea9d05e581a466de19e1c89ee74fc4bf528f60c8600edd" + sha256: b3c895bdf936c77b83c5254bec2e6b3f066710c1f89c38b20b8acc382b525494 url: "https://pub.dev" source: hosted - version: "0.3.6" + version: "0.3.8" window_size: dependency: "direct main" description: @@ -2499,10 +2457,10 @@ packages: dependency: transitive description: name: xdg_directories - sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" xml: dependency: transitive description: @@ -2523,10 +2481,10 @@ packages: dependency: "direct main" description: name: youtube_explode_dart - sha256: "98fd11b51adbbca76cbdb17f560168f1d7a9835cecceea965f49eb1e5eed155c" + sha256: "12d32dffd8c85927eb46f7cf7a9dfce690edfe82134c08a90529c51eba58a85c" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.2.0" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.16.0" + flutter: ">=3.19.2" diff --git a/pubspec.yaml b/pubspec.yaml index 3f4c22af..62c20c35 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,96 +13,89 @@ environment: flutter: ">=3.10.0" dependencies: - args: ^2.3.2 + args: ^2.5.0 async: ^2.9.0 - audio_service: ^0.18.9 - audio_session: ^0.1.18 + audio_service: ^0.18.13 + audio_service_mpris: ^0.1.3 + audio_session: ^0.1.19 auto_size_text: ^3.0.0 - buttons_tabbar: ^1.3.6 + buttons_tabbar: ^1.3.8 cached_network_image: ^3.3.1 - catcher_2: 1.0.0 + catcher_2: ^1.2.4 collection: ^1.15.0 - cupertino_icons: ^1.0.5 curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 - device_info_plus: ^9.1.2 + device_info_plus: ^10.1.0 device_preview: ^1.1.0 - dio: ^5.4.1 - disable_battery_optimization: ^1.1.0+1 + dio: ^5.4.3+1 + disable_battery_optimization: ^1.1.1 duration: ^3.0.12 - envied: ^0.3.0 - file_selector: ^1.0.1 - fluentui_system_icons: ^1.1.189 + envied: ^0.5.4+1 + file_picker: ^8.0.0+1 + file_selector: ^1.0.3 + fluentui_system_icons: ^1.1.234 flutter: sdk: flutter flutter_cache_manager: ^3.3.0 - flutter_desktop_tools: - git: - url: https://github.com/KRTirtho/flutter_desktop_tools.git - ref: 1f0bec3283626dcbd8ee2f54e238d096d8dea50e flutter_displaymode: ^0.6.0 flutter_feather_icons: ^2.0.0+1 flutter_hooks: ^0.20.5 flutter_inappwebview: ^6.0.0 flutter_localizations: sdk: flutter - flutter_native_splash: ^2.3.10 - flutter_riverpod: ^2.4.10 + flutter_native_splash: ^2.4.0 + flutter_riverpod: ^2.5.1 flutter_secure_storage: ^9.0.0 flutter_svg: ^1.1.6 form_validator: ^2.1.1 fuzzywuzzy: ^1.1.6 go_router: 12.1.3 # Stuck on this https://github.com/flutter/flutter/issues/140869 - google_fonts: ^6.1.0 + google_fonts: ^6.2.1 hive: ^2.2.3 hive_flutter: ^1.1.0 - hooks_riverpod: ^2.4.3 + hooks_riverpod: ^2.5.1 html: ^0.15.1 http: ^1.2.0 - image_picker: ^1.0.4 + image_picker: ^1.1.0 intl: ^0.18.0 - introduction_screen: ^3.0.2 + introduction_screen: ^3.1.14 json_annotation: ^4.8.1 logger: ^2.0.2 - media_kit: ^1.1.3 - media_kit_libs_audio: ^1.0.3 + media_kit: ^1.1.10+1 + media_kit_libs_audio: ^1.0.4 metadata_god: ^0.5.2+1 mime: ^1.0.2 - package_info_plus: ^4.1.0 + package_info_plus: ^6.0.0 palette_generator: ^0.3.3 path: ^1.8.0 - path_provider: ^2.0.8 - permission_handler: ^11.0.1 - piped_client: - git: - url: https://github.com/KRTirtho/piped_client.git + path_provider: ^2.1.3 + permission_handler: ^11.3.1 + piped_client: ^0.1.1 popover: ^0.3.0 scrobblenaut: git: url: https://github.com/KRTirtho/scrobblenaut.git ref: dart-3-support scroll_to_index: ^3.0.1 - sidebarx: ^0.16.3 - shared_preferences: ^2.2.2 + sidebarx: ^0.17.1 + shared_preferences: ^2.2.3 skeleton_text: ^3.0.1 - smtc_windows: ^0.1.1 + smtc_windows: ^0.1.2 stroke_text: ^0.0.2 system_theme: ^2.1.0 titlebar_buttons: ^1.0.0 - url_launcher: ^6.1.7 - uuid: ^3.0.7 + url_launcher: ^6.2.6 + uuid: ^4.4.0 version: ^3.0.2 visibility_detector: ^0.4.0+2 - window_manager: ^0.3.1 + window_manager: ^0.3.8 window_size: git: url: https://github.com/google/flutter-desktop-embedding.git ref: a738913c8ce2c9f47515382d40827e794a334274 path: plugins/window_size - youtube_explode_dart: ^2.0.1 - simple_icons: ^7.10.0 - audio_service_mpris: ^0.1.0 - file_picker: ^6.0.0 + youtube_explode_dart: ^2.2.0 + simple_icons: ^10.1.3 jiosaavn: ^0.1.0 draggable_scrollbar: git: @@ -116,28 +109,29 @@ 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 + skeletonizer: ^1.1.1 + app_links: ^4.0.1 + win32_registry: ^1.1.3 flutter_sharing_intent: ^1.1.0 flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 - spotify: ^0.13.3 + spotify: ^0.13.5 bonsoir: ^5.1.9 shelf: ^1.4.1 shelf_router: ^1.1.4 shelf_web_socket: ^1.0.4 - web_socket_channel: ^2.4.4 + web_socket_channel: ^2.4.5 lrc: ^1.0.2 pub_api_client: ^2.4.0 pubspec_parse: ^1.2.2 timezone: ^0.9.2 crypto: ^3.0.3 + local_notifier: ^0.1.6 + tray_manager: ^0.2.2 dev_dependencies: build_runner: ^2.4.9 - envied_generator: ^0.3.0+3 - flutter_distributor: ^0.0.2 + envied_generator: ^0.5.4+1 flutter_gen_runner: ^5.4.0 flutter_launcher_icons: ^0.13.1 flutter_lints: ^3.0.1 @@ -147,12 +141,12 @@ dev_dependencies: sdk: flutter hive_generator: ^2.0.0 json_serializable: ^6.6.2 - freezed: ^2.4.6 - custom_lint: ^0.5.11 - riverpod_lint: ^2.1.1 + freezed: ^2.5.2 + custom_lint: ^0.6.4 + riverpod_lint: ^2.3.10 dependency_overrides: - system_tray: 2.0.2 + uuid: ^4.4.0 flutter: generate: true diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index d8a9db29..57542dec 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -16,7 +16,7 @@ #include #include #include -#include +#include #include #include #include @@ -42,8 +42,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); SystemThemePluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SystemThemePlugin")); - SystemTrayPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SystemTrayPlugin")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 90292744..6a0c7723 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -13,7 +13,7 @@ list(APPEND FLUTTER_PLUGIN_LIST permission_handler_windows screen_retriever system_theme - system_tray + tray_manager url_launcher_windows window_manager window_size From 7e07c2e1985da7ccb96b1fac2ecd703720068d26 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 18 Apr 2024 00:05:47 +0600 Subject: [PATCH 060/261] fix(search): load more button not working #1417 --- lib/pages/search/sections/tracks.dart | 2 +- macos/Podfile.lock | 15 +++++---------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 48dabc13..7fb58759 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -113,7 +113,7 @@ class SearchTracksSection extends HookConsumerWidget { child: TextButton( onPressed: searchTrack.isLoadingNextPage ? null - : () => searchTrackNotifier.fetchMore, + : searchTrackNotifier.fetchMore, child: searchTrack.isLoadingNextPage ? const CircularProgressIndicator() : Text(context.l10n.load_more), diff --git a/macos/Podfile.lock b/macos/Podfile.lock index c1cf630c..ce2ef233 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -18,9 +18,6 @@ PODS: - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - local_notifier (0.1.0): - FlutterMacOS - media_kit_libs_macos_audio (1.0.4): @@ -39,9 +36,9 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.2): + - sqflite (0.0.3): + - Flutter - FlutterMacOS - - FMDB (>= 2.7.5) - system_theme (0.0.1): - FlutterMacOS - tray_manager (0.0.1): @@ -71,7 +68,7 @@ DEPENDENCIES: - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -80,7 +77,6 @@ DEPENDENCIES: SPEC REPOS: trunk: - - FMDB - OrderedSet EXTERNAL SOURCES: @@ -119,7 +115,7 @@ EXTERNAL SOURCES: shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin system_theme: :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos tray_manager: @@ -141,7 +137,6 @@ SPEC CHECKSUMS: flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 @@ -151,7 +146,7 @@ SPEC CHECKSUMS: path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 - sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 From 9bccbc93c63dd34f6e15ff68c276976ecd1d9a33 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 18 Apr 2024 00:19:47 +0600 Subject: [PATCH 061/261] fix: spotify friends and user profile icon (mobile) showing when not authenticated #1410 --- .vscode/settings.json | 1 + lib/components/home/sections/friends.dart | 47 +++++++++++++---------- lib/pages/home/home.dart | 6 +++ 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 29c5ba4e..de5fbd69 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,5 +24,6 @@ "explorer.fileNesting.patterns": { "pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,flutter_launcher_icons*.yaml,flutter_native_splash*.yaml", "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", + "*.dart": "${capture}.g.dart,${capture}.freezed.dart", } } \ No newline at end of file diff --git a/lib/components/home/sections/friends.dart b/lib/components/home/sections/friends.dart index 35ec09b0..4ae802e6 100644 --- a/lib/components/home/sections/friends.dart +++ b/lib/components/home/sections/friends.dart @@ -1,12 +1,14 @@ import 'dart:ui'; 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:spotube/collections/fake.dart'; import 'package:spotube/components/home/sections/friends/friend_item.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HomePageFriendsSection extends HookConsumerWidget { @@ -14,6 +16,7 @@ class HomePageFriendsSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final auth = ref.watch(authenticationProvider); final friendsQuery = ref.watch(friendsProvider); final friends = friendsQuery.asData?.value.friends ?? FakeData.friends.friends; @@ -27,32 +30,36 @@ class HomePageFriendsSection extends HookConsumerWidget { xxl: 7, ); - final friendGroup = friends.fold>>( - [], - (previousValue, element) { - if (previousValue.isEmpty) { + final friendGroup = useMemoized( + () => friends.fold>>( + [], + (previousValue, element) { + if (previousValue.isEmpty) { + return [ + [element] + ]; + } + + final lastGroup = previousValue.last; + if (lastGroup.length < groupCount) { + return [ + ...previousValue.sublist(0, previousValue.length - 1), + [...lastGroup, element] + ]; + } + return [ + ...previousValue, [element] ]; - } - - final lastGroup = previousValue.last; - if (lastGroup.length < groupCount) { - return [ - ...previousValue.sublist(0, previousValue.length - 1), - [...lastGroup, element] - ]; - } - - return [ - ...previousValue, - [element] - ]; - }, + }, + ), + [friends, groupCount], ); if (friendsQuery.isLoading || - friendsQuery.asData?.value.friends.isEmpty == true) { + friendsQuery.asData?.value.friends.isEmpty == true || + auth == null) { return const SliverToBoxAdapter( child: SizedBox.shrink(), ); diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 31f26bee..a4a71146 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -41,9 +42,14 @@ class HomePage extends HookConsumerWidget { const ConnectDeviceButton(), const Gap(10), Consumer(builder: (context, ref, _) { + final auth = ref.watch(authenticationProvider); final me = ref.watch(meProvider); final meData = me.asData?.value; + if (auth == null) { + return const SizedBox(); + } + return IconButton( icon: CircleAvatar( backgroundImage: UniversalImage.imageProvider( From 2da5d786d277ee8ba05685c4f98ae22e9c27d023 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 19 Apr 2024 16:05:01 +0600 Subject: [PATCH 062/261] chore: add docker and m1 based linux arm build --- .dockerignore | 4 ++ .github/Dockerfile | 32 +++++++++ .github/workflows/spotube-release-binary.yml | 74 ++++++++++++++++++-- 3 files changed, 103 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..55fee41a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +build +dist +.dart_tool +.idea diff --git a/.github/Dockerfile b/.github/Dockerfile new file mode 100644 index 00000000..e4dacb0e --- /dev/null +++ b/.github/Dockerfile @@ -0,0 +1,32 @@ +ARG FLUTTER_VERSION +ARG BUILD_VERSION + +FROM --platform=arm64 fischerscode/flutter-sudo:${FLUTTER_VERSION} + +WORKDIR /app + +# Install dependencies +RUN sudo apt-get update &&\ + sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev rpm &&\ + sudo rm -rf /var/lib/apt/lists/* + +COPY . . + +RUN sudo chown -R $(whoami) /app + +RUN flutter pub get &&\ + flutter config --enable-linux-desktop &&\ + flutter pub get &&\ + dart run build_runner build --delete-conflicting-outputs + +RUN 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=rpm + + +RUN make tar VERSION=${BUILD_VERSION} ARCH=arm64 PKG_ARCH=aarch64 + +RUN mv build/spotube-linux-*-aarch64.tar.xz dist/ &&\ + mv dist/**/spotube-*-linux.deb dist/Spotube-linux-aarch64.deb &&\ + mv dist/**/spotube-*-linux.rpm dist/Spotube-linux-aarch64.rpm \ No newline at end of file diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 969e1b77..044738c9 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -70,7 +70,7 @@ jobs: run: | flutter config --enable-windows-desktop flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + dart run build_runner build --delete-conflicting-outputs - name: Build Windows Executable run: | @@ -156,7 +156,7 @@ jobs: run: | flutter config --enable-linux-desktop flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + dart run build_runner build --delete-conflicting-outputs - name: Build Linux Packages run: | @@ -206,6 +206,66 @@ jobs: with: limit-access-to-actor: true + linux_arm: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Install Docker + run: brew install docker + + - name: Replace pubspec version and BUILD_VERSION Env (nightly) + if: ${{ inputs.channel == 'nightly' }} + run: | + brew install yq + yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml + yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml + echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV + + + - name: BUILD_VERSION Env (stable) + if: ${{ inputs.channel == 'stable' }} + run: | + echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV + + - name: Get current date + id: date + run: echo "::set-output name=date::$(date +'%Y-%m-%d')" + + - name: Replace Version in files + run: | + sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml + + - name: Create Stable .env + if: ${{ inputs.channel == 'stable' }} + run: echo '${{ secrets.DOTENV_RELEASE }}' > .env + + - name: Create Nightly .env + if: ${{ inputs.channel == 'nightly' }} + run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env + + - name: Build Linux Arm + run: | + docker build -t spotube-linux-arm -f .github/Dockerfile . --build-arg BUILD_VERSION=${{ env.BUILD_VERSION }} --build-arg FLUTTER_VERSION=${{ env.FLUTTER_VERSION }} + docker create --name spotube-linux-arm spotube-linux-arm + docker cp spotube-linux-arm:/app/dist . + docker rm -f spotube-linux-arm + + - uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + dist/Spotube-linux-aarch64.deb + dist/Spotube-linux-aarch64.rpm + dist/spotube-linux-nightly-aarch64.tar.xz + + - name: Debug With SSH When fails + if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true + android: runs-on: ubuntu-latest @@ -245,7 +305,7 @@ jobs: - name: Generate Secrets run: | flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + dart run build_runner build --delete-conflicting-outputs - name: Sign Apk run: | @@ -260,7 +320,7 @@ jobs: - name: Build Playstore AppBundle run: | echo 'ENABLE_UPDATE_CHECK=0' >> .env - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + dart run build_runner build --delete-conflicting-outputs export MANIFEST=android/app/src/main/AndroidManifest.xml xmlstarlet ed -d '//meta-data[@android:name="com.google.android.gms.car.application"]' $MANIFEST > $MANIFEST.tmp mv $MANIFEST.tmp $MANIFEST @@ -283,7 +343,6 @@ jobs: limit-access-to-actor: true macos: - runs-on: macos-14 steps: - uses: actions/checkout@v4 @@ -317,7 +376,7 @@ jobs: run: | dart pub global activate flutter_distributor flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + dart run build_runner build --delete-conflicting-outputs - name: Build Macos App run: | @@ -381,7 +440,7 @@ jobs: - name: Generate Secrets run: | flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + dart run build_runner build --delete-conflicting-outputs - name: Build iOS iPA run: | @@ -408,6 +467,7 @@ jobs: needs: - windows - linux + - linux_arm - android - macos - iOS From ef7833eb672feb591b424ece900e0b3b199fe036 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 19 Apr 2024 16:10:28 +0600 Subject: [PATCH 063/261] cd: fix sed failing us --- .github/workflows/spotube-release-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 044738c9..4979c21a 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -234,7 +234,7 @@ jobs: - name: Replace Version in files run: | - sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml + sed -i '' 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml - name: Create Stable .env if: ${{ inputs.channel == 'stable' }} From 88fea7ecf9ea426d26b6c8ad44e9b872136e8eb5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 19 Apr 2024 16:14:17 +0600 Subject: [PATCH 064/261] cd: use docker cask --- .github/workflows/spotube-release-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 4979c21a..c7753155 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -212,7 +212,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Docker - run: brew install docker + run: brew install --cask docker - name: Replace pubspec version and BUILD_VERSION Env (nightly) if: ${{ inputs.channel == 'nightly' }} From 937a706ac9c0e59943b2609e5cc398dcdbed2344 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 4 May 2024 20:10:19 +0600 Subject: [PATCH 065/261] fix: windows SSL Certificate error breaking login #905 (#1474) * fix: certificate error by using custom ssl certificate * Cd/docker linux ar (#1468) * cd: use docker buildx * cd: use linux host for linux arm instead of macos m1 m1 doesn't support nested virtualization. (Apple truly sucks) * cd: don't specify arch in Dockerfile * cd: use custom Dockerfile from ubuntu instead of flutter image * cd: add setup java for android * cd: add flutter distributor pre-built docker image for arm * cd: save me from this cursed arm build * cd: ?? * cd: ?? * cd: use docker build * fix: windows SSL Exception for Signing in * refactor: extract update checker as a basic function instead of a hook --- .dockerignore | 2 + .github/Dockerfile | 23 ++-- .github/Dockerfile.flutter_distributor | 23 ++++ .github/workflows/spotube-release-binary.yml | 83 ++++++++++----- lib/components/root/update_dialog.dart | 46 ++++++++ .../configurators/use_update_checker.dart | 100 ------------------ lib/pages/root/root_app.dart | 5 +- lib/provider/authentication_provider.dart | 40 ++++--- lib/utils/service_utils.dart | 52 ++++++++- pubspec.yaml | 1 + 10 files changed, 218 insertions(+), 157 deletions(-) create mode 100644 .github/Dockerfile.flutter_distributor create mode 100644 lib/components/root/update_dialog.dart delete mode 100644 lib/hooks/configurators/use_update_checker.dart diff --git a/.dockerignore b/.dockerignore index 55fee41a..ddfd1517 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,5 @@ build dist .dart_tool .idea +.github +.git \ No newline at end of file diff --git a/.github/Dockerfile b/.github/Dockerfile index e4dacb0e..007d1a6e 100644 --- a/.github/Dockerfile +++ b/.github/Dockerfile @@ -1,32 +1,27 @@ ARG FLUTTER_VERSION -ARG BUILD_VERSION -FROM --platform=arm64 fischerscode/flutter-sudo:${FLUTTER_VERSION} +FROM --platform=linux/arm64 krtirtho/flutter_distributor_arm64:${FLUTTER_VERSION} + +ARG BUILD_VERSION WORKDIR /app -# Install dependencies -RUN sudo apt-get update &&\ - sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev rpm &&\ - sudo rm -rf /var/lib/apt/lists/* - COPY . . -RUN sudo chown -R $(whoami) /app +RUN chown -R $(whoami) /app RUN flutter pub get &&\ flutter config --enable-linux-desktop &&\ flutter pub get &&\ dart run build_runner build --delete-conflicting-outputs -RUN 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=rpm +RUN alias dpkg-deb="dpkg-deb --Zxz" &&\ + flutter_distributor package --platform=linux --targets=deb RUN make tar VERSION=${BUILD_VERSION} ARCH=arm64 PKG_ARCH=aarch64 RUN mv build/spotube-linux-*-aarch64.tar.xz dist/ &&\ - mv dist/**/spotube-*-linux.deb dist/Spotube-linux-aarch64.deb &&\ - mv dist/**/spotube-*-linux.rpm dist/Spotube-linux-aarch64.rpm \ No newline at end of file + mv dist/**/spotube-*-linux.deb dist/Spotube-linux-aarch64.deb + +CMD [ "sleep", "5000000" ] \ No newline at end of file diff --git a/.github/Dockerfile.flutter_distributor b/.github/Dockerfile.flutter_distributor new file mode 100644 index 00000000..952b9158 --- /dev/null +++ b/.github/Dockerfile.flutter_distributor @@ -0,0 +1,23 @@ +FROM --platform=linux/arm64 ubuntu:22.04 + +ARG FLUTTER_VERSION + +RUN apt-get clean &&\ + apt-get update &&\ + apt-get install -y bash curl file git unzip xz-utils zip libglu1-mesa cmake tar clang ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev rpm && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /home/flutter + +RUN git clone https://github.com/flutter/flutter.git -b ${FLUTTER_VERSION} --single-branch flutter-sdk + +RUN flutter-sdk/bin/flutter precache + +RUN flutter-sdk/bin/flutter config --no-analytics + +ENV PATH="$PATH:/home/flutter/flutter-sdk/bin" +ENV PATH="$PATH:/home/flutter/flutter-sdk/bin/cache/dart-sdk/bin" +ENV PATH="$PATH:/home/flutter/.pub-cache/bin" +ENV PUB_CACHE="/home/flutter/.pub-cache" + +RUN dart pub global activate flutter_distributor \ No newline at end of file diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index c7753155..c7fcbf44 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -207,34 +207,35 @@ jobs: limit-access-to-actor: true linux_arm: - runs-on: macos-14 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - - name: Install Docker - run: brew install --cask docker - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - brew install yq - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - name: Get current date id: date run: echo "::set-output name=date::$(date +'%Y-%m-%d')" - - name: Replace Version in files + - name: Install Dependencies run: | - sed -i '' 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml + sudo apt-get update -y + sudo apt-get install -y pkg-config make python3-pip python3-setuptools + + - name: Replace pubspec version and BUILD_VERSION Env (nightly) + if: ${{ inputs.channel == 'nightly' }} + run: | + curl -sS https://webi.sh/yq | sh + yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml + yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml + echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV + + - name: BUILD_VERSION Env (stable) + if: ${{ inputs.channel == 'stable' }} + run: | + echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - name: Create Stable .env if: ${{ inputs.channel == 'stable' }} @@ -244,20 +245,42 @@ jobs: if: ${{ inputs.channel == 'nightly' }} run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - name: Build Linux Arm + - name: Replace Version in files run: | - docker build -t spotube-linux-arm -f .github/Dockerfile . --build-arg BUILD_VERSION=${{ env.BUILD_VERSION }} --build-arg FLUTTER_VERSION=${{ env.FLUTTER_VERSION }} - docker create --name spotube-linux-arm spotube-linux-arm - docker cp spotube-linux-arm:/app/dist . - docker rm -f spotube-linux-arm + sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml + + - name: Build Binaries (stable) + if: ${{ inputs.channel == 'stable' }} + run: | + docker buildx build --platform=linux/arm64 -f .github/Dockerfile . --build-arg FLUTTER_VERSION=${{env.FLUTTER_VERSION}} --build-arg BUILD_VERSION=${{env.BUILD_VERSION}} -t krtirtho/spotube_linux_arm:latest --load + + - name: Build Binaries (nightly) + if: ${{ inputs.channel == 'nightly' }} + run: | + docker buildx build --platform=linux/arm64 -f .github/Dockerfile . --build-arg FLUTTER_VERSION=${{env.FLUTTER_VERSION}} --build-arg BUILD_VERSION=nightly -t krtirtho/spotube_linux_arm:latest --load + + - name: Copy the built packages + run: | + docker images ls + docker create --name spotube_linux_arm krtirtho/spotube_linux_arm:latest + docker cp spotube_linux_arm:/app/dist/ dist/ - uses: actions/upload-artifact@v3 + if: ${{ inputs.channel == 'stable' }} + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + dist/Spotube-linux-aarch64.deb + dist/spotube-linux-${{ env.BUILD_VERSION }}-aarch64.tar.xz + + - uses: actions/upload-artifact@v3 + if: ${{ inputs.channel == 'nightly' }} with: if-no-files-found: error name: Spotube-Release-Binaries path: | dist/Spotube-linux-aarch64.deb - dist/Spotube-linux-aarch64.rpm dist/spotube-linux-nightly-aarch64.tar.xz - name: Debug With SSH When fails @@ -266,7 +289,6 @@ jobs: with: limit-access-to-actor: true - android: runs-on: ubuntu-latest steps: @@ -275,6 +297,13 @@ jobs: with: cache: true flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + check-latest: true - name: Install Dependencies run: | diff --git a/lib/components/root/update_dialog.dart b/lib/components/root/update_dialog.dart new file mode 100644 index 00000000..f5388aa1 --- /dev/null +++ b/lib/components/root/update_dialog.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:version/version.dart'; + +class RootAppUpdateDialog extends StatelessWidget { + final Version? version; + const RootAppUpdateDialog({super.key, this.version}); + + @override + Widget build(BuildContext context) { + const url = "https://spotube.krtirtho.dev/downloads"; + return AlertDialog( + title: const Text("Spotube has an update"), + actions: [ + FilledButton( + child: const Text("Download Now"), + onPressed: () => launchUrlString( + url, + mode: LaunchMode.externalApplication, + ), + ), + ], + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Spotube v$version has been released"), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Read the latest "), + AnchorButton( + "release notes", + style: const TextStyle(color: Colors.blue), + onTap: () => launchUrlString( + url, + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/hooks/configurators/use_update_checker.dart b/lib/hooks/configurators/use_update_checker.dart deleted file mode 100644 index 7b937efb..00000000 --- a/lib/hooks/configurators/use_update_checker.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart' as http; -import 'package:spotube/collections/env.dart'; - -import 'package:spotube/components/shared/links/anchor_button.dart'; -import 'package:spotube/hooks/controllers/use_package_info.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:version/version.dart'; - -void useUpdateChecker(WidgetRef ref) { - final isCheckUpdateEnabled = - ref.watch(userPreferencesProvider.select((s) => s.checkUpdate)); - final packageInfo = usePackageInfo( - appName: 'Spotube', - packageName: 'spotube', - ); - final Future> Function() checkUpdate = useCallback( - () async { - final value = await http.get( - Uri.parse( - "https://api.github.com/repos/KRTirtho/spotube/releases/latest"), - ); - final tagName = - (jsonDecode(value.body)["tag_name"] as String).replaceAll("v", ""); - final currentVersion = packageInfo.version == "Unknown" - ? null - : Version.parse(packageInfo.version); - final latestVersion = - tagName == "nightly" ? null : Version.parse(tagName); - return [currentVersion, latestVersion]; - }, - [packageInfo.version], - ); - - final context = useContext(); - - download(String url) => launchUrlString( - url, - mode: LaunchMode.externalApplication, - ); - - useEffect(() { - if (!Env.enableUpdateChecker) return; - if (!isCheckUpdateEnabled) return null; - checkUpdate().then((value) { - final currentVersion = value.first; - final latestVersion = value.last; - if (currentVersion == null || - latestVersion == null || - (latestVersion.isPreRelease && !currentVersion.isPreRelease) || - (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return; - if (latestVersion <= currentVersion) return; - showDialog( - context: context, - barrierDismissible: true, - barrierColor: Colors.black26, - builder: (context) { - const url = - "https://spotube.krtirtho.dev/downloads"; - return AlertDialog( - title: const Text("Spotube has an update"), - actions: [ - FilledButton( - child: const Text("Download Now"), - onPressed: () => download(url), - ), - ], - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text("Spotube v${value.last} has been released"), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("Read the latest "), - AnchorButton( - "release notes", - style: const TextStyle(color: Colors.blue), - onTap: () => launchUrlString( - url, - mode: LaunchMode.externalApplication, - ), - ), - ], - ), - ], - ), - ); - }, - ); - }); - return null; - }, [packageInfo, isCheckUpdateEnabled]); -} diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index f3ed6571..42bf3f69 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -14,13 +14,13 @@ import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; -import 'package:spotube/hooks/configurators/use_update_checker.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/service_utils.dart'; const rootPaths = { "/": 0, @@ -46,6 +46,8 @@ class RootApp extends HookConsumerWidget { useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { + ServiceUtils.checkForUpdates(context, ref); + final sharedPreferences = await SharedPreferences.getInstance(); if (sharedPreferences.getBool(kIsUsingEncryption) == false && @@ -160,7 +162,6 @@ class RootApp extends HookConsumerWidget { }, [downloader]); // checks for latest version of the application - useUpdateChecker(ref); useEndlessPlayback(ref); diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index a82f82c0..c94f4f3e 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -1,10 +1,12 @@ import 'dart:async'; -import 'dart:convert'; +import 'dart:io'; import 'package:collection/collection.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart' + hide X509Certificate; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/extensions/context.dart'; @@ -18,6 +20,18 @@ class AuthenticationCredentials { bool get isExpired => DateTime.now().isAfter(expiration); + static final Dio dio = () { + final dio = Dio(); + + (dio.httpClientAdapter as IOHttpClientAdapter) + .createHttpClient = () => HttpClient() + ..badCertificateCallback = (X509Certificate cert, String host, int port) { + return host.endsWith("spotify.com") && port == 443; + }; + + return dio; + }(); + AuthenticationCredentials({ required this.cookie, required this.accessToken, @@ -30,21 +44,23 @@ class AuthenticationCredentials { .split("; ") .firstWhereOrNull((c) => c.trim().startsWith("sp_dc=")) ?.trim(); - final res = await get( + final res = await dio.getUri( Uri.parse( "https://open.spotify.com/get_access_token?reason=transport&productType=web_player", ), - headers: { - "Cookie": spDc ?? "", - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" - }, + options: Options( + headers: { + "Cookie": spDc ?? "", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" + }, + ), ); - final body = jsonDecode(res.body); + final body = res.data; - if (res.statusCode >= 400) { + if ((res.statusCode ?? 500) >= 400) { throw Exception( - "Failed to get access token: ${body['error'] ?? res.reasonPhrase}", + "Failed to get access token: ${body['error'] ?? res.statusMessage}", ); } diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 88c52896..30c92e1d 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -1,10 +1,10 @@ import 'dart:convert'; -import 'package:flutter/widgets.dart' hide Element; import 'package:go_router/go_router.dart'; -import 'package:html/dom.dart'; +import 'package:html/dom.dart' hide Text; import 'package:spotify/spotify.dart'; import 'package:spotube/components/library/user_local_tracks.dart'; +import 'package:spotube/components/root/update_dialog.dart'; import 'package:spotube/models/logger.dart'; import 'package:http/http.dart' as http; import 'package:spotube/models/lyrics.dart'; @@ -14,6 +14,16 @@ import 'package:spotube/utils/primitive_utils.dart'; import 'package:collection/collection.dart'; import 'package:html/parser.dart' as parser; +import 'dart:async'; + +import 'package:flutter/material.dart' hide Element; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:spotube/collections/env.dart'; + +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:version/version.dart'; + abstract class ServiceUtils { static final logger = getLogger("ServiceUtils"); @@ -318,4 +328,42 @@ abstract class ServiceUtils { } }); } + + static Future checkForUpdates( + BuildContext context, + WidgetRef ref, + ) async { + if (!Env.enableUpdateChecker) return; + if (!ref.read(userPreferencesProvider.select((s) => s.checkUpdate))) return; + + final packageInfo = await PackageInfo.fromPlatform(); + + final value = await http.get( + Uri.parse( + "https://api.github.com/repos/KRTirtho/spotube/releases/latest", + ), + ); + final tagName = + (jsonDecode(value.body)["tag_name"] as String).replaceAll("v", ""); + final currentVersion = packageInfo.version == "Unknown" + ? null + : Version.parse(packageInfo.version); + final latestVersion = tagName == "nightly" ? null : Version.parse(tagName); + + if (currentVersion == null || + latestVersion == null || + (latestVersion.isPreRelease && !currentVersion.isPreRelease) || + (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return; + + if (latestVersion <= currentVersion || !context.mounted) return; + + showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.black26, + builder: (context) { + return RootAppUpdateDialog(version: latestVersion); + }, + ); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 62c20c35..20acd3d4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -153,6 +153,7 @@ flutter: uses-material-design: true assets: - assets/ + - assets/ca/ - assets/tutorial/ - assets/logos/ - LICENSE From 7ad67fa3fa6cb44b926bedf2f682f589a9b1b206 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 4 May 2024 20:39:36 +0600 Subject: [PATCH 066/261] cd: fix windows build error due to nightly version format --- .github/workflows/spotube-release-binary.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index c7fcbf44..6139bacb 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -42,9 +42,10 @@ jobs: if: ${{ inputs.channel == 'nightly' }} run: | choco install sed make yq -y - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml + yq -i '.version |= sub("\+\d+", "-${{ inputs.channel }}+")' pubspec.yaml yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $env:GITHUB_ENV + "BUILD_VERSION=${{ inputs.version }}-${{ inputs.channel }}+${{ github.run_number }}" >> $env:GITHUB_ENV + shell: bash - name: BUILD_VERSION Env (stable) if: ${{ inputs.channel == 'stable' }} From c1a105a1ffed7207120cffed812b7a890ec63368 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 4 May 2024 21:00:06 +0600 Subject: [PATCH 067/261] cd: fix github versioning scheme --- .github/workflows/spotube-release-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 6139bacb..cabe2dbf 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -44,7 +44,7 @@ jobs: choco install sed make yq -y yq -i '.version |= sub("\+\d+", "-${{ inputs.channel }}+")' pubspec.yaml yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - "BUILD_VERSION=${{ inputs.version }}-${{ inputs.channel }}+${{ github.run_number }}" >> $env:GITHUB_ENV + echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV shell: bash - name: BUILD_VERSION Env (stable) From 2286277a062833e541fde625376acd6a0a03b48e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 4 May 2024 21:26:45 +0600 Subject: [PATCH 068/261] chore: remove assets/ca entry in pubspec.yaml --- pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 20acd3d4..62c20c35 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -153,7 +153,6 @@ flutter: uses-material-design: true assets: - assets/ - - assets/ca/ - assets/tutorial/ - assets/logos/ - LICENSE From 4ca893950b07f678acf7db690112c47d21e54782 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 5 May 2024 09:15:52 +0600 Subject: [PATCH 069/261] fix(macos): Logs directory not created by default #1353 --- lib/models/logger.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/models/logger.dart b/lib/models/logger.dart index 4f687d09..3236028d 100644 --- a/lib/models/logger.dart +++ b/lib/models/logger.dart @@ -27,7 +27,7 @@ Future getLogsPath() async { } final file = File(path.join(dir, ".spotube_logs")); if (!await file.exists()) { - await file.create(); + await file.create(recursive: true); } return file; } From a77b6776e81d88d665a7368fa0fb71b65933afb8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 9 May 2024 15:26:58 +0600 Subject: [PATCH 070/261] refactor: Dart based Github Workflow CLI (#1490) * feat: add build dart script for windows * feat: add android build support * feat: add linux build support * feat: add macos build support * feat: add ios build support * feat: add deps install command and workflow file * cd: what? * cd: what? * cd: what? * cd: update workflow inputs * cd: replace release binary * cd: run flutter pub get * cd: use dpkg zstd instead of xz, windows disable innoInstall, fix channel enum.name and reset pubspec after changing build no for nightly * cd: fix tar copy path * cd: fix copy linux command * cd: fix windows inno depend and fix android aab path * cd: idk * cd: linux why??? * cd: windows choco copy failed * cd: use dart tar archive for creating tar file * cd: fix linux file copy error * cd: use tar command directly * feat: add linux_arm platform * cd: add linux_arm platform * cd: don't know what? * feat: notification about nightly channel update * chore: fix some errors parsing nightly version info --- .env.example | 3 + .github/Dockerfile | 8 +- .github/workflows/spotube-release-binary.yml | 523 +++---------------- .metadata | 16 +- cli/README.md | 4 + cli/cli.dart | 16 + cli/commands/build.dart | 25 + cli/commands/build/android.dart | 90 ++++ cli/commands/build/common.dart | 66 +++ cli/commands/build/ios.dart | 29 + cli/commands/build/linux.dart | 106 ++++ cli/commands/build/linux_arm.dart | 37 ++ cli/commands/build/macos.dart | 42 ++ cli/commands/build/windows.dart | 100 ++++ cli/commands/install-dependencies.dart | 74 +++ cli/core/env.dart | 24 + lib/collections/env.dart | 12 + lib/components/root/update_dialog.dart | 42 +- lib/pages/settings/about.dart | 8 + lib/utils/service_utils.dart | 74 ++- pubspec.lock | 24 +- pubspec.yaml | 3 + windows/CMakeLists.txt | 29 +- windows/runner/Runner.rc | 14 +- 24 files changed, 837 insertions(+), 532 deletions(-) create mode 100644 cli/README.md create mode 100644 cli/cli.dart create mode 100644 cli/commands/build.dart create mode 100644 cli/commands/build/android.dart create mode 100644 cli/commands/build/common.dart create mode 100644 cli/commands/build/ios.dart create mode 100644 cli/commands/build/linux.dart create mode 100644 cli/commands/build/linux_arm.dart create mode 100644 cli/commands/build/macos.dart create mode 100644 cli/commands/build/windows.dart create mode 100644 cli/commands/install-dependencies.dart create mode 100644 cli/core/env.dart diff --git a/.env.example b/.env.example index 22abd24b..56665663 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,6 @@ ENABLE_UPDATE_CHECK= LASTFM_API_KEY= LASTFM_API_SECRET= + +# Release channel. Can be: nightly, stable +RELEASE_CHANNEL= diff --git a/.github/Dockerfile b/.github/Dockerfile index 007d1a6e..2e393449 100644 --- a/.github/Dockerfile +++ b/.github/Dockerfile @@ -10,14 +10,10 @@ COPY . . RUN chown -R $(whoami) /app -RUN flutter pub get &&\ - flutter config --enable-linux-desktop &&\ - flutter pub get &&\ - dart run build_runner build --delete-conflicting-outputs +RUN flutter pub get RUN alias dpkg-deb="dpkg-deb --Zxz" &&\ - flutter_distributor package --platform=linux --targets=deb - + flutter_distributor package --platform=linux --targets=deb --skip-clean RUN make tar VERSION=${BUILD_VERSION} ARCH=arm64 PKG_ARCH=aarch64 diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index cabe2dbf..0fe1f1ba 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -2,296 +2,65 @@ name: Spotube Release Binary on: workflow_dispatch: inputs: - version: - description: Version to release (x.x.x) - default: 3.6.0 - required: true channel: type: choice - description: Release Channel - required: true options: - stable - nightly default: nightly + description: The release channel debug: - description: Debug on failed when channel is nightly - required: true type: boolean default: false + description: Debug with SSH toggle + required: false dry_run: - description: Dry run - required: true type: boolean - default: true + default: false + description: Dry run without uploading to release env: - FLUTTER_VERSION: '3.19.5' + FLUTTER_VERSION: 3.19.5 + +permissions: + contents: write jobs: - windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - choco install sed make yq -y - yq -i '.version |= sub("\+\d+", "-${{ inputs.channel }}+")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - shell: bash - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - "BUILD_VERSION=${{ inputs.version }}" >> $env:GITHUB_ENV - - - name: Replace version in files - run: | - choco install sed make -y - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" windows/runner/Runner.rc - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" choco-struct/tools/VERIFICATION.txt - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" choco-struct/spotube.nuspec - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generating Secrets - run: | - flutter config --enable-windows-desktop - flutter pub get - dart run build_runner build --delete-conflicting-outputs - - - name: Build Windows Executable - run: | - dart pub global activate flutter_distributor - make innoinstall - 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 - if: ${{ inputs.channel == 'stable' }} - run: | - Set-Variable -Name HASH -Value (Get-FileHash dist\Spotube-windows-x86_64-setup.exe).Hash - sed -i "s/%{{WIN_SHA256}}%/$HASH/" choco-struct/tools/VERIFICATION.txt - make choco - mv dist/spotube.*.nupkg dist/Spotube-windows-x86_64.nupkg - - - - name: Upload Artifact - uses: actions/upload-artifact@v3 - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-windows-x86_64.nupkg - dist/Spotube-windows-x86_64-setup.exe - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - linux: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Get current date - id: date - run: echo "::set-output name=date::$(date +'%Y-%m-%d')" - - - name: Install Dependencies - run: | - sudo apt-get update -y - sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev - - - name: Install AppImage Tool - run: | - wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" - chmod +x appimagetool - mv appimagetool /usr/local/bin/ - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - curl -sS https://webi.sh/yq | sh - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Replace Version in files - run: | - sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml - - - name: Generate Secrets - run: | - flutter config --enable-linux-desktop - flutter pub get - dart run build_runner build --delete-conflicting-outputs - - - name: Build Linux Packages - run: | - 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=rpm - - - name: Create tar.xz (stable) - if: ${{ inputs.channel == 'stable' }} - run: make tar VERSION=${{ env.BUILD_VERSION }} ARCH=x64 PKG_ARCH=x86_64 - - - name: Create tar.xz (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: make tar VERSION=nightly ARCH=x64 PKG_ARCH=x86_64 - - - name: Move Files to dist - run: | - 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 - - - - uses: actions/upload-artifact@v3 - if: ${{ inputs.channel == 'stable' }} - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-linux-x86_64.deb - dist/Spotube-linux-x86_64.rpm - dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz - - - uses: actions/upload-artifact@v3 - if: ${{ inputs.channel == 'nightly' }} - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-linux-x86_64.deb - dist/Spotube-linux-x86_64.rpm - dist/spotube-linux-nightly-x86_64.tar.xz - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - linux_arm: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Get current date - id: date - run: echo "::set-output name=date::$(date +'%Y-%m-%d')" - - - name: Install Dependencies - run: | - sudo apt-get update -y - sudo apt-get install -y pkg-config make python3-pip python3-setuptools - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - curl -sS https://webi.sh/yq | sh - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Replace Version in files - run: | - sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml - - - name: Build Binaries (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - docker buildx build --platform=linux/arm64 -f .github/Dockerfile . --build-arg FLUTTER_VERSION=${{env.FLUTTER_VERSION}} --build-arg BUILD_VERSION=${{env.BUILD_VERSION}} -t krtirtho/spotube_linux_arm:latest --load - - - name: Build Binaries (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - docker buildx build --platform=linux/arm64 -f .github/Dockerfile . --build-arg FLUTTER_VERSION=${{env.FLUTTER_VERSION}} --build-arg BUILD_VERSION=nightly -t krtirtho/spotube_linux_arm:latest --load - - - name: Copy the built packages - run: | - docker images ls - docker create --name spotube_linux_arm krtirtho/spotube_linux_arm:latest - docker cp spotube_linux_arm:/app/dist/ dist/ - - - uses: actions/upload-artifact@v3 - if: ${{ inputs.channel == 'stable' }} - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-linux-aarch64.deb - dist/spotube-linux-${{ env.BUILD_VERSION }}-aarch64.tar.xz - - - uses: actions/upload-artifact@v3 - if: ${{ inputs.channel == 'nightly' }} - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-linux-aarch64.deb - dist/spotube-linux-nightly-aarch64.tar.xz - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - android: - runs-on: ubuntu-latest + build_platform: + strategy: + matrix: + include: + - os: ubuntu-latest + platform: linux + files: | + dist/Spotube-linux-x86_64.deb + dist/Spotube-linux-x86_64.rpm + dist/spotube-linux-*-x86_64.tar.xz + - os: ubuntu-latest + platform: linux_arm + files: | + dist/Spotube-linux-aarch64.deb + dist/spotube-linux-*-aarch64.tar.xz + - os: ubuntu-latest + platform: android + files: | + build/Spotube-android-all-arch.apk + build/Spotube-playstore-all-arch.aab + - os: windows-latest + platform: windows + files: | + dist/Spotube-windows-x86_64.nupkg + dist/Spotube-windows-x86_64-setup.exe + - os: macos-latest + platform: ios + files: | + Spotube-iOS.ipa + - os: macos-14 + platform: macos + files: | + build/Spotube-macos-universal.dmg + build/Spotube-macos-universal.pkg + runs-on: ${{matrix.os}} steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.12.0 @@ -299,72 +68,42 @@ jobs: cache: true flutter-version: ${{ env.FLUTTER_VERSION }} - name: Setup Java + if: ${{matrix.platform == 'android'}} uses: actions/setup-java@v4 with: distribution: 'zulu' java-version: '17' cache: 'gradle' check-latest: true + - name: Set up QEMU + if: ${{matrix.platform == 'linux_arm'}} + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + if: ${{matrix.platform == 'linux_arm'}} + uses: docker/setup-buildx-action@v3 - - name: Install Dependencies - run: | - sudo apt-get update -y - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse xmlstarlet - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - curl -sS https://webi.sh/yq | sh - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generate Secrets + - name: Install ${{matrix.platform}} dependencies run: | flutter pub get - dart run build_runner build --delete-conflicting-outputs + dart cli/cli.dart install-dependencies --platform=${{matrix.platform}} - name: Sign Apk + if: ${{matrix.platform == 'android'}} run: | echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties - - - name: Build Apk - run: | - flutter build apk --flavor ${{ inputs.channel }} - mv build/app/outputs/flutter-apk/app-${{ inputs.channel }}-release.apk build/Spotube-android-all-arch.apk - - - name: Build Playstore AppBundle - run: | - echo 'ENABLE_UPDATE_CHECK=0' >> .env - dart run build_runner build --delete-conflicting-outputs - export MANIFEST=android/app/src/main/AndroidManifest.xml - xmlstarlet ed -d '//meta-data[@android:name="com.google.android.gms.car.application"]' $MANIFEST > $MANIFEST.tmp - mv $MANIFEST.tmp $MANIFEST - flutter build appbundle --flavor ${{ inputs.channel }} - mv build/app/outputs/bundle/${{ inputs.channel }}Release/app-${{ inputs.channel }}-release.aab build/Spotube-playstore-all-arch.aab - - + + - name: Build ${{matrix.platform}} binaries + run: dart cli/cli.dart build ${{matrix.platform}} + env: + CHANNEL: ${{inputs.channel}} + DOTENV: ${{secrets.DOTENV_RELEASE}} + - uses: actions/upload-artifact@v3 with: if-no-files-found: error name: Spotube-Release-Binaries - path: | - build/Spotube-android-all-arch.apk - build/Spotube-playstore-all-arch.aab + path: ${{matrix.files}} - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} @@ -372,135 +111,10 @@ jobs: with: limit-access-to-actor: true - macos: - runs-on: macos-14 - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - brew install yq - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generate Secrets - run: | - dart pub global activate flutter_distributor - flutter pub get - dart run build_runner build --delete-conflicting-outputs - - - name: Build Macos App - run: | - flutter config --enable-macos-desktop - flutter build macos - du -sh build/macos/Build/Products/Release/spotube.app - - - name: Package Macos App - run: | - brew install python-setuptools - npm install -g appdmg - mkdir -p build/${{ env.BUILD_VERSION }} - appdmg appdmg.json build/Spotube-macos-universal.dmg - flutter_distributor package --platform=macos --targets pkg --skip-clean - mv dist/**/spotube-*-macos.pkg build/Spotube-macos-universal.pkg - - - uses: actions/upload-artifact@v3 - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - build/Spotube-macos-universal.dmg - build/Spotube-macos-universal.pkg - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - iOS: - runs-on: macos-14 - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.10.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - brew install yq - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generate Secrets - run: | - flutter pub get - dart run build_runner build --delete-conflicting-outputs - - - name: Build iOS iPA - run: | - flutter build ios --release --no-codesign --flavor ${{ inputs.channel }} - ln -sf ./build/ios/iphoneos Payload - zip -r9 Spotube-iOS.ipa Payload/${{ inputs.channel }}.app - - - uses: actions/upload-artifact@v3 - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - Spotube-iOS.ipa - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - upload: runs-on: ubuntu-latest - needs: - - windows - - linux - - linux_arm - - android - - macos - - iOS + - build_platform steps: - uses: actions/download-artifact@v3 with: @@ -516,6 +130,10 @@ jobs: md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum + + - name: Extract pubspec version + run: | + echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV - uses: actions/upload-artifact@v3 with: @@ -530,7 +148,7 @@ jobs: uses: ncipollo/release-action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} - tag: v${{ inputs.version }} # mind the "v" prefix + tag: v${{ env.PUBSPEC_VERSION }} # mind the "v" prefix omitBodyDuringUpdate: true omitNameDuringUpdate: true omitPrereleaseDuringUpdate: true @@ -548,3 +166,8 @@ jobs: omitPrereleaseDuringUpdate: true allowUpdates: true artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum + body: | + Build Number: ${{github.run_number}} + + Nightly release includes newest features but may contain bugs + It is preferred to use the stable version unless you know what you're doing diff --git a/.metadata b/.metadata index 082985ad..828f2c0a 100644 --- a/.metadata +++ b/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: eb6d86ee27deecba4a83536aa20f366a6044895c - channel: stable + revision: "300451adae589accbece3490f4396f10bdf15e6e" + channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c - base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c - - platform: macos - create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c - base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + create_revision: 300451adae589accbece3490f4396f10bdf15e6e + base_revision: 300451adae589accbece3490f4396f10bdf15e6e + - platform: windows + create_revision: 300451adae589accbece3490f4396f10bdf15e6e + base_revision: 300451adae589accbece3490f4396f10bdf15e6e # User provided section diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..b2ba8ebd --- /dev/null +++ b/cli/README.md @@ -0,0 +1,4 @@ +## Spotube Configuration CLI + +This is used for building the project for multiple platforms and having utilities specific for the project. +Written in Dart diff --git a/cli/cli.dart b/cli/cli.dart new file mode 100644 index 00000000..3210f557 --- /dev/null +++ b/cli/cli.dart @@ -0,0 +1,16 @@ +import 'package:args/command_runner.dart'; + +import 'commands/build.dart'; +import 'commands/install-dependencies.dart'; + +void main(List args) { + final commandRunner = CommandRunner( + "cli", + "Configuration CLI for Spotube", + ); + + commandRunner.addCommand(InstallDependenciesCommand()); + commandRunner.addCommand(BuildCommand()); + + commandRunner.run(args); +} diff --git a/cli/commands/build.dart b/cli/commands/build.dart new file mode 100644 index 00000000..fdf35a95 --- /dev/null +++ b/cli/commands/build.dart @@ -0,0 +1,25 @@ +import 'package:args/command_runner.dart'; + +import 'build/android.dart'; +import 'build/ios.dart'; +import 'build/linux.dart'; +import 'build/linux_arm.dart'; +import 'build/macos.dart'; +import 'build/windows.dart'; + +class BuildCommand extends Command { + @override + String get description => "Build for different platforms"; + + @override + String get name => "build"; + + BuildCommand() { + addSubcommand(AndroidBuildCommand()); + addSubcommand(IosBuildCommand()); + addSubcommand(LinuxBuildCommand()); + addSubcommand(LinuxArmBuildCommand()); + addSubcommand(MacosBuildCommand()); + addSubcommand(WindowsBuildCommand()); + } +} diff --git a/cli/commands/build/android.dart b/cli/commands/build/android.dart new file mode 100644 index 00000000..800522b8 --- /dev/null +++ b/cli/commands/build/android.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:collection/collection.dart'; +import 'package:path/path.dart'; +import 'package:xml/xml.dart'; + +import '../../core/env.dart'; +import 'common.dart'; + +class AndroidBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Build for android"; + + @override + String get name => "android"; + + @override + FutureOr? run() async { + await bootstrap(); + + await shell.run( + "flutter build apk --flavor ${CliEnv.channel.name}", + ); + + await dotEnvFile.writeAsString( + "\nENABLE_UPDATE_CHECK=0", + mode: FileMode.append, + ); + + final androidManifestFile = File( + join(cwd.path, "android", "app", "src", "main", "AndroidManifest.xml")); + + final androidManifestXml = + XmlDocument.parse(await androidManifestFile.readAsString()); + + final deletingElement = + androidManifestXml.findAllElements("meta-data").firstWhereOrNull( + (el) => + el.getAttribute("android:name") == + "com.google.android.gms.car.application", + ); + + deletingElement?.parent?.children.remove(deletingElement); + + await androidManifestFile.writeAsString( + androidManifestXml.toXmlString(pretty: true), + ); + + await shell.run( + """ + dart run build_runner build --delete-conflicting-outputs + flutter build appbundle --flavor ${CliEnv.channel.name} + """, + ); + + final ogApkFile = File( + join( + "build", + "app", + "outputs", + "flutter-apk", + "app-${CliEnv.channel.name}-release.apk", + ), + ); + + await ogApkFile.copy( + join(cwd.path, "build", "Spotube-android-all-arch.apk"), + ); + + final ogAppbundleFile = File( + join( + cwd.path, + "build", + "app", + "outputs", + "bundle", + "${CliEnv.channel.name}Release", + "app-${CliEnv.channel.name}-release.aab", + ), + ); + + await ogAppbundleFile.copy( + join(cwd.path, "build", "Spotube-playstore-all-arch.aab"), + ); + + stdout.writeln("✅ Built Android Apk and Appbundle"); + } +} diff --git a/cli/commands/build/common.dart b/cli/commands/build/common.dart new file mode 100644 index 00000000..4c7e3e51 --- /dev/null +++ b/cli/commands/build/common.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; +import 'package:process_run/shell_run.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; + +import '../../core/env.dart'; + +mixin BuildCommandCommonSteps on Command { + final shell = Shell(); + Directory get cwd => Directory.current; + + Pubspec? _pubspec; + + Pubspec get pubspec { + if (_pubspec != null) { + return _pubspec!; + } + + final pubspecFile = File(join(cwd.path, "pubspec.yaml")); + _pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); + + return _pubspec!; + } + + String get versionWithoutBuildNumber { + return "${pubspec.version!.major}.${pubspec.version!.minor}.${pubspec.version!.patch}"; + } + + RegExp get versionVarRegExp => + RegExp(r"\%\{\{SPOTUBE_VERSION\}\}\%", multiLine: true); + + File get dotEnvFile => File(join(cwd.path, ".env")); + + Future bootstrap() async { + await dotEnvFile.create(recursive: true); + + await dotEnvFile.writeAsString( + "${CliEnv.dotenv}\n" + "RELEASE_CHANNEL=${CliEnv.channel.name}\n", + ); + + if (CliEnv.channel == BuildChannel.nightly) { + final pubspecFile = File(join(cwd.path, "pubspec.yaml")); + + pubspecFile.writeAsStringSync( + pubspecFile.readAsStringSync().replaceAll( + "version: ${pubspec.version!.canonicalizedVersion}", + "version: $versionWithoutBuildNumber+${CliEnv.ghRunNumber}", + ), + ); + + _pubspec = null; + pubspec; + } + + await shell.run( + """ + flutter pub get + dart run build_runner build --delete-conflicting-outputs + dart pub global activate flutter_distributor + """, + ); + } +} diff --git a/cli/commands/build/ios.dart b/cli/commands/build/ios.dart new file mode 100644 index 00000000..6460f9ed --- /dev/null +++ b/cli/commands/build/ios.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; + +import '../../core/env.dart'; +import 'common.dart'; + +class IosBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "iOS build command"; + + @override + String get name => "ios"; + + @override + FutureOr? run() async { + await bootstrap(); + + final buildDirPath = join(cwd.path, "build", "ios", "iphoneos"); + await shell.run( + """ + flutter build ios --release --no-codesign --flavor ${CliEnv.channel.name} + ln -sf $buildDirPath Payload + zip -r9 Spotube-iOS.ipa ${join("Payload", "${CliEnv.channel.name}.app")} + """, + ); + } +} diff --git a/cli/commands/build/linux.dart b/cli/commands/build/linux.dart new file mode 100644 index 00000000..a218720c --- /dev/null +++ b/cli/commands/build/linux.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:io/io.dart'; +import 'package:args/command_runner.dart'; +import 'package:intl/intl.dart'; +import 'package:path/path.dart'; + +import '../../core/env.dart'; +import 'common.dart'; + +class LinuxBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Linux build command"; + + @override + String get name => "linux"; + + @override + FutureOr? run() async { + stdout.writeln("Replacing versions"); + + final appDataFile = File( + join(cwd.path, "linux", "com.github.KRTirtho.Spotube.appdata.xml"), + ); + + appDataFile.writeAsStringSync( + appDataFile.readAsStringSync().replaceAll( + versionVarRegExp, + '', + ), + ); + + await bootstrap(); + + await shell.run( + """ + flutter_distributor package --platform=linux --targets=deb + flutter_distributor package --platform=linux --targets=rpm + """, + ); + + final tempDir = join(Directory.systemTemp.path, "spotube-tar"); + + final bundleDirPath = + join(cwd.path, "build", "linux", "x64", "release", "bundle"); + + final tarFile = File(join( + cwd.path, + "dist", + "spotube-linux-" + "${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}" + "-x86_64.tar.xz", + )); + + await copyPath(bundleDirPath, tempDir); + await File(join(cwd.path, "linux", "spotube.desktop")).copy( + join(tempDir, "spotube.desktop"), + ); + await File( + join(cwd.path, "linux", "com.github.KRTirtho.Spotube.appdata.xml"), + ).copy( + join(tempDir, "com.github.KRTirtho.Spotube.appdata.xml"), + ); + await File(join(cwd.path, "assets", "spotube-logo.png")).copy( + join(tempDir, "spotube-logo.png"), + ); + + await shell.run( + "tar -cJf ${tarFile.path} -C $tempDir .", + ); + + final ogDeb = File( + join( + cwd.path, + "dist", + pubspec.version.toString(), + "spotube-${pubspec.version}-linux.deb", + ), + ); + + final ogRpm = File( + join( + cwd.path, + "dist", + pubspec.version.toString(), + "spotube-${pubspec.version}-linux.rpm", + ), + ); + + await ogDeb.copy( + join(cwd.path, "dist", "Spotube-linux-x86_64.deb"), + ); + await ogRpm.copy( + join(cwd.path, "dist", "Spotube-linux-x86_64.rpm"), + ); + + await ogDeb.delete(); + await ogRpm.delete(); + + stdout.writeln("✅ Linux building done"); + } +} diff --git a/cli/commands/build/linux_arm.dart b/cli/commands/build/linux_arm.dart new file mode 100644 index 00000000..a09f0980 --- /dev/null +++ b/cli/commands/build/linux_arm.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; + +import '../../core/env.dart'; +import 'common.dart'; + +class LinuxArmBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Build Linux Arm"; + + @override + String get name => "linux_arm"; + + @override + FutureOr? run() async { + await bootstrap(); + + await shell.run( + "docker buildx build --platform=linux/arm64 " + "-f ${join(cwd.path, ".github", "Dockerfile")} ${cwd.path} " + "--build-arg FLUTTER_VERSION=${CliEnv.flutterVersion} " + "--build-arg BUILD_VERSION=${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber} " + "-t krtirtho/spotube_linux_arm:latest " + "--load", + ); + + await shell.run( + """ + docker images ls + docker create --name spotube_linux_arm krtirtho/spotube_linux_arm:latest + docker cp spotube_linux_arm:/app/dist/ dist/ + """, + ); + } +} diff --git a/cli/commands/build/macos.dart b/cli/commands/build/macos.dart new file mode 100644 index 00000000..e8f34b77 --- /dev/null +++ b/cli/commands/build/macos.dart @@ -0,0 +1,42 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; + +import 'common.dart'; + +class MacosBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Macos Build command"; + + @override + String get name => "macos"; + + @override + FutureOr? run() async { + await bootstrap(); + + await shell.run( + """ + flutter build macos + appdmg appdmg.json ${join(cwd.path, "build", "Spotube-macos-universal.dmg")} + flutter_distributor package --platform=macos --targets pkg --skip-clean + """, + ); + + final ogPkg = File( + join( + cwd.path, + "dist", + pubspec.version.toString(), + "spotube-${pubspec.version}-macos.pkg", + ), + ); + + await ogPkg.copy( + join(cwd.path, "build", "Spotube-macos-universal.pkg"), + ); + await ogPkg.delete(); + } +} diff --git a/cli/commands/build/windows.dart b/cli/commands/build/windows.dart new file mode 100644 index 00000000..15e0bf17 --- /dev/null +++ b/cli/commands/build/windows.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; +import 'package:crypto/crypto.dart'; +import 'common.dart'; + +class WindowsBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Build Windows exe"; + + @override + String get name => "windows"; + + Future innoDependInstall() async { + final innoDependencyPath = join(cwd.path, "build", "inno-depend"); + + await shell.run( + "git clone https://github.com/DomGries/InnoDependencyInstaller.git $innoDependencyPath", + ); + } + + @override + void run() async { + stdout.writeln("Replace versions"); + + final chocoFiles = [ + join(cwd.path, "choco-struct", "tools", "VERIFICATION.txt"), + join(cwd.path, "choco-struct", "spotube.nuspec"), + ]; + + for (final filePath in chocoFiles) { + final file = File(filePath); + final content = file.readAsStringSync(); + final newContent = + content.replaceAll(versionVarRegExp, versionWithoutBuildNumber); + + file.writeAsStringSync(newContent); + } + + await bootstrap(); + await innoDependInstall(); + + await shell.run( + "flutter_distributor package --platform=windows --targets=exe --skip-clean", + ); + + final ogExe = File( + join( + cwd.path, + "dist", + pubspec.version.toString(), + "spotube-${pubspec.version}-windows-setup.exe", + ), + ); + + final exePath = join(cwd.path, "dist", "Spotube-windows-x86_64-setup.exe"); + + await ogExe.copy(exePath); + await ogExe.delete(); + + stdout.writeln("✅ Windows exe built at $exePath"); + + final exeFile = File(exePath); + + final hash = sha256.convert(await exeFile.readAsBytes()).toString(); + + final chocoVerificationFile = File(chocoFiles.first); + + chocoVerificationFile.writeAsStringSync( + chocoVerificationFile.readAsStringSync().replaceAll( + RegExp(r"\%\{\{WIN_SHA256\}\}\%"), + hash, + ), + ); + + await exeFile.copy( + join(cwd.path, "choco-struct", "tools", basename(exeFile.path)), + ); + + await shell.run( + "choco pack ${chocoFiles[1]} --outputdirectory ${join(cwd.path, "dist")}", + ); + + final chocoNupkg = File( + join(cwd.path, "dist", "spotube.$versionWithoutBuildNumber.nupkg"), + ); + + final distNupkgPath = join( + cwd.path, + "dist", + "Spotube-windows-x86_64.nupkg", + ); + + await chocoNupkg.copy(distNupkgPath); + await chocoNupkg.delete(); + + stdout.writeln("✅ Windows nupkg built at $distNupkgPath"); + } +} diff --git a/cli/commands/install-dependencies.dart b/cli/commands/install-dependencies.dart new file mode 100644 index 00000000..75df28df --- /dev/null +++ b/cli/commands/install-dependencies.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:process_run/shell_run.dart'; + +class InstallDependenciesCommand extends Command { + @override + String get description => "Install platform dependencies"; + + @override + String get name => "install-dependencies"; + + InstallDependenciesCommand() { + argParser.addOption( + "platform", + abbr: "p", + allowed: [ + "windows", + "linux", + "linux_arm", + "macos", + "ios", + "android", + ], + mandatory: true, + ); + } + + @override + FutureOr? run() async { + final shell = Shell(); + + switch (argResults!.option("platform")) { + case "windows": + break; + case "linux": + await shell.run( + """ + sudo apt-get update -y + sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev + """, + ); + break; + case "linux_arm": + await shell.run( + """ + sudo apt-get update -y + sudo apt-get install -y pkg-config make python3-pip python3-setuptools + """, + ); + break; + case "macos": + await shell.run( + """ + brew install python-setuptools + npm install -g appdmg + """, + ); + break; + case "ios": + break; + case "android": + await shell.run( + """ + sudo apt-get update -y + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse + """, + ); + break; + default: + break; + } + } +} diff --git a/cli/core/env.dart b/cli/core/env.dart new file mode 100644 index 00000000..33cc5df1 --- /dev/null +++ b/cli/core/env.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +enum BuildChannel { + stable, + nightly; + + factory BuildChannel.fromEnvironment(String name) { + final channel = Platform.environment[name]!; + if (channel == "stable") { + return BuildChannel.stable; + } else if (channel == "nightly") { + return BuildChannel.nightly; + } else { + throw Exception("Invalid channel: $channel"); + } + } +} + +class CliEnv { + static final channel = BuildChannel.fromEnvironment("CHANNEL"); + static final dotenv = Platform.environment["DOTENV"]!; + static final ghRunNumber = Platform.environment["GITHUB_RUN_NUMBER"]; + static final flutterVersion = Platform.environment["FLUTTER_VERSION"]!; +} diff --git a/lib/collections/env.dart b/lib/collections/env.dart index 14f33b80..89a777b6 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -3,6 +3,11 @@ import 'package:spotube/utils/platform.dart'; part 'env.g.dart'; +enum ReleaseChannel { + nightly, + stable, +} + @Envied(obfuscate: true, requireEnvFile: true, path: ".env") abstract class Env { @EnviedField(varName: 'SPOTIFY_SECRETS') @@ -25,6 +30,13 @@ abstract class Env { @EnviedField(varName: 'ENABLE_UPDATE_CHECK', defaultValue: "1") static final String _enableUpdateChecker = _Env._enableUpdateChecker; + @EnviedField(varName: "RELEASE_CHANNEL", defaultValue: "nightly") + static final String _releaseChannel = _Env._releaseChannel; + + static ReleaseChannel get releaseChannel => _releaseChannel == "stable" + ? ReleaseChannel.stable + : ReleaseChannel.nightly; + static bool get enableUpdateChecker => kIsFlatpak || _enableUpdateChecker == "1"; diff --git a/lib/components/root/update_dialog.dart b/lib/components/root/update_dialog.dart index f5388aa1..e15903c6 100644 --- a/lib/components/root/update_dialog.dart +++ b/lib/components/root/update_dialog.dart @@ -5,18 +5,23 @@ import 'package:version/version.dart'; class RootAppUpdateDialog extends StatelessWidget { final Version? version; - const RootAppUpdateDialog({super.key, this.version}); + final int? nightlyBuildNum; + + const RootAppUpdateDialog({super.key, this.version}) : nightlyBuildNum = null; + const RootAppUpdateDialog.nightly({super.key, required this.nightlyBuildNum}) + : version = null; @override Widget build(BuildContext context) { const url = "https://spotube.krtirtho.dev/downloads"; + const nightlyUrl = "https://spotube.krtirtho.dev/downloads/nightly"; return AlertDialog( title: const Text("Spotube has an update"), actions: [ FilledButton( child: const Text("Download Now"), onPressed: () => launchUrlString( - url, + nightlyBuildNum != null ? nightlyUrl : url, mode: LaunchMode.externalApplication, ), ), @@ -24,21 +29,26 @@ class RootAppUpdateDialog extends StatelessWidget { content: Column( mainAxisSize: MainAxisSize.min, children: [ - Text("Spotube v$version has been released"), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("Read the latest "), - AnchorButton( - "release notes", - style: const TextStyle(color: Colors.blue), - onTap: () => launchUrlString( - url, - mode: LaunchMode.externalApplication, - ), - ), - ], + Text( + nightlyBuildNum != null + ? "Spotube Nightly $nightlyBuildNum has been released" + : "Spotube v$version has been released", ), + if (nightlyBuildNum == null) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Read the latest "), + AnchorButton( + "release notes", + style: const TextStyle(color: Colors.blue), + onTap: () => launchUrlString( + url, + mode: LaunchMode.externalApplication, + ), + ), + ], + ), ], ), ); diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 21b8117b..505eecb9 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/env.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; @@ -72,6 +73,13 @@ class AboutSpotube extends HookConsumerWidget { Text("v${packageInfo.version}") ], ), + TableRow( + children: [ + Text(context.l10n.channel), + colon, + Text(Env.releaseChannel.name) + ], + ), TableRow( children: [ Text(context.l10n.build_number), diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 30c92e1d..ec3bb0cb 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -335,35 +335,59 @@ abstract class ServiceUtils { ) async { if (!Env.enableUpdateChecker) return; if (!ref.read(userPreferencesProvider.select((s) => s.checkUpdate))) return; - final packageInfo = await PackageInfo.fromPlatform(); - final value = await http.get( - Uri.parse( - "https://api.github.com/repos/KRTirtho/spotube/releases/latest", - ), - ); - final tagName = - (jsonDecode(value.body)["tag_name"] as String).replaceAll("v", ""); - final currentVersion = packageInfo.version == "Unknown" - ? null - : Version.parse(packageInfo.version); - final latestVersion = tagName == "nightly" ? null : Version.parse(tagName); + if (Env.releaseChannel == ReleaseChannel.nightly) { + final value = await http.get( + Uri.parse( + "https://api.github.com/repos/KRTirtho/spotube/actions/workflows/spotube-release-binary.yml/runs?status=success&per_page=1", + ), + ); - if (currentVersion == null || - latestVersion == null || - (latestVersion.isPreRelease && !currentVersion.isPreRelease) || - (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return; + final buildNum = + jsonDecode(value.body)["workflow_runs"][0]["run_number"] as int; - if (latestVersion <= currentVersion || !context.mounted) return; + if (buildNum <= int.parse(packageInfo.buildNumber) || !context.mounted) { + return; + } - showDialog( - context: context, - barrierDismissible: true, - barrierColor: Colors.black26, - builder: (context) { - return RootAppUpdateDialog(version: latestVersion); - }, - ); + await showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.black26, + builder: (context) { + return RootAppUpdateDialog.nightly(nightlyBuildNum: buildNum); + }, + ); + } else { + final value = await http.get( + Uri.parse( + "https://api.github.com/repos/KRTirtho/spotube/releases/latest", + ), + ); + final tagName = + (jsonDecode(value.body)["tag_name"] as String).replaceAll("v", ""); + final currentVersion = packageInfo.version == "Unknown" + ? null + : Version.parse(packageInfo.version); + final latestVersion = + tagName == "nightly" ? null : Version.parse(tagName); + + if (currentVersion == null || + latestVersion == null || + (latestVersion.isPreRelease && !currentVersion.isPreRelease) || + (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return; + + if (latestVersion <= currentVersion || !context.mounted) return; + + showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.black26, + builder: (context) { + return RootAppUpdateDialog(version: latestVersion); + }, + ); + } } } diff --git a/pubspec.lock b/pubspec.lock index 1532bcf7..df623b9e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: archive - sha256: "22600aa1e926be775fa5fe7e6894e7fb3df9efda8891c73f70fb3262399a432d" + sha256: ecf4273855368121b1caed0d10d4513c7241dfc813f7d3c8933b36622ae9b265 url: "https://pub.dev" source: hosted - version: "3.4.10" + version: "3.5.1" args: dependency: "direct main" description: @@ -1271,7 +1271,7 @@ packages: source: hosted version: "3.1.14" io: - dependency: transitive + dependency: "direct dev" description: name: io sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" @@ -1702,14 +1702,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "70fe966348fe08c34bf929582f1d8247d9d9408130723206472b4687227e4333" - url: "https://pub.dev" - source: hosted - version: "3.8.0" pool: dependency: transitive description: @@ -1734,6 +1726,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" + process_run: + dependency: "direct dev" + description: + name: process_run + sha256: "8d9c6198b98fbbfb511edd42e7364e24d85c163e47398919871b952dc86a423e" + url: "https://pub.dev" + source: hosted + version: "0.14.2" provider: dependency: transitive description: @@ -2462,7 +2462,7 @@ packages: source: hosted version: "1.0.4" xml: - dependency: transitive + dependency: "direct dev" description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 diff --git a/pubspec.yaml b/pubspec.yaml index 62c20c35..7435e077 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -144,6 +144,9 @@ dev_dependencies: freezed: ^2.5.2 custom_lint: ^0.6.4 riverpod_lint: ^2.3.10 + process_run: ^0.14.2 + xml: ^6.5.0 + io: ^1.0.4 dependency_overrides: uuid: ^4.4.0 diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 6e1e3cb3..0c638eb7 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -1,13 +1,16 @@ +# Project-level configuration. cmake_minimum_required(VERSION 3.14) project(spotube LANGUAGES CXX) +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. set(BINARY_NAME "spotube") -cmake_policy(SET CMP0063 NEW) +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Configure build options. +# Define build configuration option. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if(IS_MULTICONFIG) set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" @@ -20,7 +23,7 @@ else() "Debug" "Profile" "Release") endif() endif() - +# Define settings for the Profile build mode. set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") @@ -30,6 +33,10 @@ set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") add_definitions(-DUNICODE -D_UNICODE) # Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_17) target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") @@ -38,14 +45,14 @@ function(APPLY_STANDARD_SETTINGS TARGET) target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") endfunction() -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") - # Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") add_subdirectory(${FLUTTER_MANAGED_DIR}) -# Application build +# Application build; see runner/CMakeLists.txt. add_subdirectory("runner") + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) @@ -80,6 +87,12 @@ if(PLUGIN_BUNDLED_LIBRARIES) COMPONENT Runtime) endif() +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index e8fccc8a..0b586d33 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -60,16 +60,16 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" // Version // -#ifdef FLUTTER_BUILD_NUMBER -#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else -#define VERSION_AS_NUMBER 1,0,0 +#define VERSION_AS_NUMBER 1,0,0,0 #endif -#ifdef FLUTTER_BUILD_NAME -#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION #else -#define VERSION_AS_STRING "%{{SPOTUBE_VERSION}}%" +#define VERSION_AS_STRING "3.6.0" #endif VS_VERSION_INFO VERSIONINFO @@ -93,7 +93,7 @@ BEGIN VALUE "FileDescription", "Spotube" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "spotube" "\0" - VALUE "LegalCopyright", "Copyright (C) 2022 oss.krtirtho. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 oss.krtirtho. All rights reserved." "\0" VALUE "OriginalFilename", "spotube.exe" "\0" VALUE "ProductName", "spotube" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" From a838eadc12a4c4acc8a3d1d76b547515e1b6d5e0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 9 May 2024 16:47:28 +0600 Subject: [PATCH 071/261] refactor: move dart scripts as commands under CLI --- bin/gen-credits.dart | 103 ----------------------------- bin/translated_messages.dart | 28 -------- bin/untranslated_messages.dart | 50 --------------- bin/verify-pkgbuild.dart | 22 ------- cli/cli.dart | 4 ++ cli/commands/credits.dart | 114 +++++++++++++++++++++++++++++++++ cli/commands/translated.dart | 39 +++++++++++ cli/commands/untranslated.dart | 48 ++++++++++++++ 8 files changed, 205 insertions(+), 203 deletions(-) delete mode 100644 bin/gen-credits.dart delete mode 100644 bin/translated_messages.dart delete mode 100644 bin/untranslated_messages.dart delete mode 100644 bin/verify-pkgbuild.dart create mode 100644 cli/commands/credits.dart create mode 100644 cli/commands/translated.dart create mode 100644 cli/commands/untranslated.dart diff --git a/bin/gen-credits.dart b/bin/gen-credits.dart deleted file mode 100644 index f8975335..00000000 --- a/bin/gen-credits.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:developer'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:http/http.dart'; -import 'package:html/parser.dart'; -import 'package:pub_api_client/pub_api_client.dart'; -import 'package:pubspec_parse/pubspec_parse.dart'; - -void main() async { - final client = PubClient(); - - final pubspec = Pubspec.parse(File('pubspec.yaml').readAsStringSync()); - - final allDeps = [ - ...pubspec.dependencies.entries, - ...pubspec.devDependencies.entries, - ]; - - final dependencies = allDeps - .where((d) => d.value is HostedDependency) - .map((d) => d.key) - .toSet(); - final packageInfo = await Future.wait(dependencies.map(client.packageInfo)); - - final gitDepsList = List.castFrom, - MapEntry>( - allDeps - .where((d) => d.value is GitDependency) - .map((d) => MapEntry(d.key, d.value as GitDependency)) - .toList(), - ); - - final gitDeps = gitDepsList.map( - (d) { - final uri = Uri.parse( - d.value.url.toString().replaceAll('.git', ''), - ); - return MapEntry( - d.key, - uri.replace( - pathSegments: [ - ...uri.pathSegments, - 'raw', - d.value.ref ?? 'main', - d.value.path ?? '', - 'pubspec.yaml', - ], - ).toString(), - ); - }, - ).toList(); - - final gitPubspecs = await Future.wait( - gitDeps.map( - (d) { - Pubspec parser(res) { - try { - return Pubspec.parse(res.body); - } catch (e) { - final document = parse(res.body); - final pre = document.querySelector('pre'); - if (pre == null) { - log(d.toString()); - rethrow; - } - return Pubspec.parse(pre.text); - } - } - - return get(Uri.parse(d.value)).then(parser).catchError( - (_) => get(Uri.parse(d.value.replaceFirst('/main', '/master'))) - .then(parser), - ); - }, - ), - ); - - // ignore: avoid_print - print( - packageInfo - .map( - (package) => - '1. [${package.name}](${package.latestPubspec.homepage ?? package.url}) - ${package.description.replaceAll('\n', '')}', - ) - .join('\n'), - ); - // ignore: avoid_print - print( - gitPubspecs.map( - (package) { - final packageUrl = package.homepage ?? - gitDepsList - .firstWhereOrNull((dep) => dep.key == package.name) - ?.value - .url - .toString(); - return '1. [${package.name}]($packageUrl) - ${package.description?.replaceAll('\n', '')}'; - }, - ).join('\n'), - ); - exit(0); -} diff --git a/bin/translated_messages.dart b/bin/translated_messages.dart deleted file mode 100644 index 1ac8f148..00000000 --- a/bin/translated_messages.dart +++ /dev/null @@ -1,28 +0,0 @@ -// ignore_for_file: avoid_print - -import 'dart:convert'; -import 'dart:io'; - -void main(List args) async { - final translatedFile = - jsonDecode(await File('tm.json').readAsString()) as Map; - - for (final MapEntry(:key, :value) in translatedFile.entries) { - print('Updating locale: $key'); - final file = File('lib/l10n/app_$key.arb'); - - final fileContent = - jsonDecode(await file.readAsString()) as Map; - - final newContent = { - ...fileContent, - ...value, - }; - - await file.writeAsString( - const JsonEncoder.withIndent(' ').convert(newContent), - ); - - print('✅ Updated locale: $key'); - } -} diff --git a/bin/untranslated_messages.dart b/bin/untranslated_messages.dart deleted file mode 100644 index 0b3485a7..00000000 --- a/bin/untranslated_messages.dart +++ /dev/null @@ -1,50 +0,0 @@ -// ignore_for_file: avoid_print - -import 'dart:convert'; -import 'dart:io'; - -/// Generate JSON output for untranslated messages with English values -/// for quick translation in ChatGPT -/// -/// Usage: dart bin/untranslated_messages.dart [locale?] -/// -/// Example: dart bin/untranslated_messages.dart -/// -/// or with specific locale (e.g. bn (Bengali)) -/// -/// Example: dart bin/untranslated_messages.dart bn - -void main(List args) { - final file = jsonDecode( - File('untranslated_messages.json').readAsStringSync(), - ) as Map; - - final englishMessages = - jsonDecode(File('lib/l10n/app_en.arb').readAsStringSync()) - as Map; - - final messagesWithValues = {}; - - for (final MapEntry(key: locale, value: messages) in file.entries) { - messagesWithValues[locale] = Map.fromEntries( - messages - .map( - (message) => - MapEntry(message, englishMessages[message]), - ) - .toList() - .cast>(), - ); - } - - 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.", - ); - print( - const JsonEncoder.withIndent(' ').convert( - args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues, - ), - ); -} diff --git a/bin/verify-pkgbuild.dart b/bin/verify-pkgbuild.dart deleted file mode 100644 index 587e63d0..00000000 --- a/bin/verify-pkgbuild.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -void main() { - Process.run("sh", ["-c", '"./scripts/pkgbuild2json.sh aur-struct/PKGBUILD"']) - .then((result) { - try { - final pkgbuild = jsonDecode(result.stdout); - if (pkgbuild["version"] != - Platform.environment["RELEASE_VERSION"]?.substring(1)) { - throw Exception( - "PKGBUILD version doesn't match current RELEASE_VERSION"); - } - if (pkgbuild["release"] != "1") { - throw Exception("In new releases pkgrel should be 1"); - } - } catch (e) { - // ignore: avoid_print - print("[Failed to parse PKGBUILD] $e"); - } - }); -} diff --git a/cli/cli.dart b/cli/cli.dart index 3210f557..074c5b12 100644 --- a/cli/cli.dart +++ b/cli/cli.dart @@ -1,7 +1,9 @@ import 'package:args/command_runner.dart'; import 'commands/build.dart'; +import 'commands/credits.dart'; import 'commands/install-dependencies.dart'; +import 'commands/untranslated.dart'; void main(List args) { final commandRunner = CommandRunner( @@ -11,6 +13,8 @@ void main(List args) { commandRunner.addCommand(InstallDependenciesCommand()); commandRunner.addCommand(BuildCommand()); + commandRunner.addCommand(CreditsCommand()); + commandRunner.addCommand(UntranslatedCommand()); commandRunner.run(args); } diff --git a/cli/commands/credits.dart b/cli/commands/credits.dart new file mode 100644 index 00000000..66ec1172 --- /dev/null +++ b/cli/commands/credits.dart @@ -0,0 +1,114 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:collection/collection.dart'; +import 'package:http/http.dart'; +import 'package:html/parser.dart'; +import 'package:path/path.dart'; +import 'package:pub_api_client/pub_api_client.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; + +class CreditsCommand extends Command { + @override + String get description => "Generate credits for used Library's authors"; + + @override + String get name => "credits"; + + @override + run() async { + final client = PubClient(); + final cwd = Directory.current; + + final pubspec = Pubspec.parse( + File(join(cwd.path, 'pubspec.yaml')).readAsStringSync(), + ); + + final allDeps = [ + ...pubspec.dependencies.entries, + ...pubspec.devDependencies.entries, + ]; + + final dependencies = allDeps + .where((d) => d.value is HostedDependency) + .map((d) => d.key) + .toSet(); + final packageInfo = await Future.wait(dependencies.map(client.packageInfo)); + + final gitDepsList = List.castFrom, + MapEntry>( + allDeps + .where((d) => d.value is GitDependency) + .map((d) => MapEntry(d.key, d.value as GitDependency)) + .toList(), + ); + + final gitDeps = gitDepsList.map( + (d) { + final uri = Uri.parse( + d.value.url.toString().replaceAll('.git', ''), + ); + return MapEntry( + d.key, + uri.replace( + pathSegments: [ + ...uri.pathSegments, + 'raw', + d.value.ref ?? 'main', + d.value.path ?? '', + 'pubspec.yaml', + ], + ).toString(), + ); + }, + ).toList(); + + final gitPubspecs = await Future.wait( + gitDeps.map( + (d) { + Pubspec parser(res) { + try { + return Pubspec.parse(res.body); + } catch (e) { + final document = parse(res.body); + final pre = document.querySelector('pre'); + if (pre == null) { + stdout.writeln(d.toString()); + rethrow; + } + return Pubspec.parse(pre.text); + } + } + + return get(Uri.parse(d.value)).then(parser).catchError( + (_) => get(Uri.parse(d.value.replaceFirst('/main', '/master'))) + .then(parser), + ); + }, + ), + ); + + stdout.writeln( + packageInfo + .map( + (package) => + '1. [${package.name}](${package.latestPubspec.homepage ?? package.url}) - ${package.description.replaceAll('\n', '')}', + ) + .join('\n'), + ); + + stdout.writeln( + gitPubspecs.map( + (package) { + final packageUrl = package.homepage ?? + gitDepsList + .firstWhereOrNull((dep) => dep.key == package.name) + ?.value + .url + .toString(); + return '1. [${package.name}]($packageUrl) - ${package.description?.replaceAll('\n', '')}'; + }, + ).join('\n'), + ); + } +} diff --git a/cli/commands/translated.dart b/cli/commands/translated.dart new file mode 100644 index 00000000..43c4ea49 --- /dev/null +++ b/cli/commands/translated.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'dart:convert'; +import 'dart:io'; +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; + +class TranslatedCommand extends Command { + @override + String get description => + "Update translation based on generated translated messages"; + + @override + String get name => "translated"; + + @override + FutureOr? run() async { + final cwd = Directory.current; + final translatedFile = jsonDecode( + await File(join(cwd.path, 'tm.json')).readAsString(), + ) as Map; + + for (final MapEntry(:key, :value) in translatedFile.entries) { + stdout.writeln('Updating locale: $key'); + final file = File(join(cwd.path, 'lib', 'l10n', 'app_$key.arb')); + + final fileContent = + jsonDecode(await file.readAsString()) as Map; + + final newContent = {...fileContent, ...value}; + + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(newContent), + ); + + stdout.writeln('✅ Updated locale: $key'); + } + } +} diff --git a/cli/commands/untranslated.dart b/cli/commands/untranslated.dart new file mode 100644 index 00000000..dadcd8b5 --- /dev/null +++ b/cli/commands/untranslated.dart @@ -0,0 +1,48 @@ +import 'package:args/command_runner.dart'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart'; + +class UntranslatedCommand extends Command { + @override + get name => "untranslated"; + @override + get description => + "Generate Untranslated Messages for ChatGPT based Translation"; + + @override + run() async { + final cwd = Directory.current; + final file = jsonDecode( + File(join(cwd.path, 'untranslated_messages.json')).readAsStringSync(), + ) as Map; + + final englishMessages = jsonDecode( + File(join(cwd.path, 'lib', 'l10n', 'app_en.arb')).readAsStringSync(), + ) as Map; + + final messagesWithValues = {}; + + for (final MapEntry(key: locale, value: messages) in file.entries) { + messagesWithValues[locale] = Map.fromEntries( + messages + .map( + (message) => + MapEntry(message, englishMessages[message]), + ) + .toList() + .cast>(), + ); + } + + stdout.writeln( + "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.", + ); + stdout.writeln( + const JsonEncoder.withIndent(' ').convert(messagesWithValues), + ); + } +} From 2b01e4fb4d816f98581ff3b6e2330008caa1273e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 9 May 2024 16:50:42 +0600 Subject: [PATCH 072/261] chore: add translated message command to command list --- cli/cli.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/cli.dart b/cli/cli.dart index 074c5b12..26190d4c 100644 --- a/cli/cli.dart +++ b/cli/cli.dart @@ -3,6 +3,7 @@ import 'package:args/command_runner.dart'; import 'commands/build.dart'; import 'commands/credits.dart'; import 'commands/install-dependencies.dart'; +import 'commands/translated.dart'; import 'commands/untranslated.dart'; void main(List args) { @@ -14,6 +15,7 @@ void main(List args) { commandRunner.addCommand(InstallDependenciesCommand()); commandRunner.addCommand(BuildCommand()); commandRunner.addCommand(CreditsCommand()); + commandRunner.addCommand(TranslatedCommand()); commandRunner.addCommand(UntranslatedCommand()); commandRunner.run(args); From dbc1c452dd53153c61589f956ea9836cea7bf2bb Mon Sep 17 00:00:00 2001 From: Josu Igoa Date: Fri, 10 May 2024 18:22:56 +0200 Subject: [PATCH 073/261] feat(translations): add Basque translation (#1493) * added Basque translation * chore: fix country codes and language native name --------- Co-authored-by: Kingkor Roy Tirtho --- lib/collections/language_codes.dart | 8 +- lib/l10n/app_eu.arb | 324 ++++++++++++++++++++++++++++ lib/l10n/l10n.dart | 1 + 3 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 lib/l10n/app_eu.arb diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index 45456d69..dcc42657 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -81,10 +81,10 @@ abstract class LanguageLocals { // name: "Bashkir", // nativeName: "башҡорт теле", // ), - // "eu": const ISOLanguageName( - // name: "Basque", - // nativeName: "euskara,", - // ), + "eu": const ISOLanguageName( + name: "Basque", + nativeName: "euskara", + ), // "be": const ISOLanguageName( // name: "Belarusian", // nativeName: "Беларуская", diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb new file mode 100644 index 00000000..9a4ebb46 --- /dev/null +++ b/lib/l10n/app_eu.arb @@ -0,0 +1,324 @@ +{ + "guest": "Gonbidatua", + "browse": "Arakatu", + "search": "Bilatu", + "library": "Liburutegia", + "lyrics": "Hitzak", + "settings": "Ezarpenak", + "genre_categories_filter": "Kategoria edo generoak filtratu...", + "genre": "Generoa", + "personalized": "Pertsonalizatua", + "featured": "Nabarmenduak", + "new_releases": "Argitaratze berriak", + "songs": "Abestiak", + "playing_track": "{track} erreproduzitzen", + "queue_clear_alert": "Uneko zerrenda ezabatuko da. {track_length} abesti ezabatuko dira.\nJarraitu nahi duzu?", + "load_more": "Gehiago kargatu", + "playlists": "Zerrendak", + "artists": "Artistak", + "albums": "Albumak", + "tracks": "Kantak", + "downloads": "Deskargak", + "filter_playlists": "Zure zerrendak filtratu...", + "liked_tracks": "Gustuko Kantak", + "liked_tracks_description": "Zure gustuko kanta guztiak", + "create_playlist": "Sortu zerrenda", + "create_a_playlist": "Sortu zerrenda bat", + "update_playlist": "Eguneratu zerrenda", + "create": "Sortu", + "cancel": "Ezeztatu", + "update": "Eguneratu", + "playlist_name": "Zerrenda Izena", + "name_of_playlist": "Zerrendaren izena", + "description": "Deskribapena", + "public": "Publikoa", + "collaborative": "Kolaboratiboa", + "search_local_tracks": "Bilatu kanta lokalak...", + "play": "Erreproduzitu", + "delete": "Ezabatu", + "none": "Batere ez", + "sort_a_z": "Ordenatu A-Z", + "sort_z_a": "Ordenatu Z-A", + "sort_artist": "Ordenatu Artistaren arabera", + "sort_album": "Ordenatu Albumaren arabera", + "sort_duration": "Ordenar Iraupenaren arabera", + "sort_tracks": "Ordenatu Kantak", + "currently_downloading": "Oraintxe ({tracks_length}) deskargatzen", + "cancel_all": "Ezeztatu dena", + "filter_artist": "Filtratu artistak...", + "followers": "{followers} Jarraitzaile", + "add_artist_to_blacklist": "Gehitu artista zerrenda beltzera", + "top_tracks": "Top Kantak", + "fans_also_like": "Fan-ek hau ere gustuko dute", + "loading": "Kargatzen...", + "artist": "Artista", + "blacklisted": "Zerrenda beltzean", + "following": "Jarraitzen", + "follow": "Jarraitu", + "artist_url_copied": "Artistaren URL-a arbelera kopiatua", + "added_to_queue": "{tracks} kanta zerrendara gehituak", + "filter_albums": "Albumak filtratu...", + "synced": "Sinkronizatuta", + "plain": "Arrunta", + "shuffle": "Ausaz", + "search_tracks": "Bilatu kantak...", + "released": "Argitaratua", + "error": "Errorea: {error}", + "title": "Izenburua", + "time": "Iraupena", + "more_actions": "Ekintza gehiago", + "download_count": "({count}) deskarga", + "add_count_to_playlist": "Gehitu ({count}) zerrendara", + "add_count_to_queue": "Gehitu ({count}) ilarara", + "play_count_next": "Erreproduzitu hurrengo ({count})-ak", + "album": "Albuma", + "copied_to_clipboard": "{data} arbelean kopiatua", + "add_to_following_playlists": "Gehitu {track} hurrengo erreprodukzio-zerrendetara", + "add": "Gehitu", + "added_track_to_queue": "{track} zerrendan gehitua", + "add_to_queue": "Gehitu zerrendan", + "track_will_play_next": "{track} erreproduzituko da ondoren", + "play_next": "Hurrengo erreprodukzioa", + "removed_track_from_queue": "{track} zerrendatik ezabatua", + "remove_from_queue": "Ezabatu ilaratik", + "remove_from_favorites": "Ezabatu gogokoetatik", + "save_as_favorite": "Gorde gogokoetan", + "add_to_playlist": "Gehitu zerrendara", + "remove_from_playlist": "Ezabatu zerrendatik", + "add_to_blacklist": "Gehitu zerrenda beltzera", + "remove_from_blacklist": "Ezabatu zerrenda beltzetik", + "share": "Elkarbanatu", + "mini_player": "Mini Erreproduzitzailea", + "slide_to_seek": "Arrastatu aurrerantz edo atzearantz bilatzeko", + "shuffle_playlist": "Erreproduzitu zerrenda ausazko ordenean", + "unshuffle_playlist": "Desgaitu ausazko erreprodukzioa", + "previous_track": "Aurreko pista", + "next_track": "Hurrengo pista", + "pause_playback": "Pausatu erreprodukzioa", + "resume_playback": "Berrabiarazi erreprodukzioa", + "loop_track": "Kanta begiztan", + "repeat_playlist": "Errepikatu lista", + "queue": "Ilara", + "alternative_track_sources": "Kanten iturri alternatiboak", + "download_track": "Deskargatu kanta", + "tracks_in_queue": "{tracks} kanta zerrendan", + "clear_all": "Garbitu dena", + "show_hide_ui_on_hover": "Erakutsi/Ezkutatu interfazea kurtsorea pasatzean", + "always_on_top": "Beti ikusgai", + "exit_mini_player": "Irten mini erreproduzitzailetik", + "download_location": "Deskargen kokapena", + "account": "Kontua", + "login_with_spotify": "Hasi saioa zure Spotify kontuarekin", + "connect_with_spotify": "Spotify-rekin konektatu", + "logout": "Itxi saioa", + "logout_of_this_account": "Itxi kontu honen saioa", + "language_region": "Hizkuntza eta Herrialdea", + "language": "Hizkuntza", + "system_default": "Sisteman lehenetsia", + "market_place_region": "Dendaren herrialdea", + "recommendation_country": "Gomendio herrialdea", + "appearance": "Itxura", + "layout_mode": "Diseinu modua", + "override_layout_settings": "Responsive diseinu moduaren ezarpenak ezeztatu", + "adaptive": "Moldagarria", + "compact": "Trinkoa", + "extended": "Hedatua", + "theme": "Gaia", + "dark": "Iluna", + "light": "Argia", + "system": "Sistema", + "accent_color": "Azentu kolorea", + "sync_album_color": "Sinkronizatu albumaren kolorea", + "sync_album_color_description": "Albumaren artearen kolore nagusia erabili azentu kolore bezala", + "playback": "Erreprodukzioa", + "audio_quality": "Audioaren kalitatea", + "high": "Altua", + "low": "Baxua", + "pre_download_play": "Aurre-deskargatu eta erreproduzitu", + "pre_download_play_description": "Streaming egin beharrean, byte-ak deskargatu eta erreproduzitu (banda-zabalera handia duten erabiltzaileentzat gomendagarria)", + "skip_non_music": "Musika ez diren segmentuak baztertu (SponsorBlock)", + "blacklist_description": "Zerrenda beltzeko abesti eta artistak", + "wait_for_download_to_finish": "Mesedez, itxaron uneko deskarga bukatu arte", + "desktop": "Mahaigaina", + "close_behavior": "Ixterako Portaera", + "close": "Itxi", + "minimize_to_tray": "Sistemako erretilura minimizatu", + "show_tray_icon": "Erakutsi ikonoa sistemaren erretiluan", + "about": "Honi buruz", + "u_love_spotube": "Badakigu Spotube maite duzula", + "check_for_updates": "Bilatu eguneraketak", + "about_spotube": "Spotube-ri buruz", + "blacklist": "Zerrenda beltza", + "please_sponsor": "Mesedez, babestu/diruz lagundu", + "spotube_description": "Spotube, arina, plataforma-anitza eta doakoa den Spotify-ren bezeroa", + "version": "Bertsioa", + "build_number": "Konpilazio zenbakia", + "founder": "Sortzailea", + "repository": "Errepositorioa", + "bug_issues": "Erroreak eta arazoak", + "made_with": "Bangladesh🇧🇩-en ❤️-z egina", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Lizentzia", + "add_spotify_credentials": "Gehitu zure Spotify kredentzialak hasi ahal izateko", + "credentials_will_not_be_shared_disclaimer": "Ez arduratu, zure kredentzialak ez ditugu bilduko edo inorekin elkarbanatuko", + "know_how_to_login": "Ez dakizu nola egin?", + "follow_step_by_step_guide": "Jarraitu pausoz-pausoko gida", + "spotify_cookie": "Spotify-ren {name} cookiea", + "cookie_name_cookie": "{name} cookiea", + "fill_in_all_fields": "Mesedez, osatu eremu guztiak", + "submit": "Bidali", + "exit": "Irten", + "previous": "Aurrekoa", + "next": "Hurrengoa", + "done": "Eginda", + "step_1": "1. pausua", + "first_go_to": "Hasteko, joan hona", + "login_if_not_logged_in": "eta hasi saioa/sortu kontua lehendik ez baduzu eginda", + "step_2": "2. pausua", + "step_2_steps": "1. Saioa hasita duzularik, sakatu F12 edo saguaren eskuineko botoia klikatu > Ikuskatu nabigatzaileko garapen tresnak irekitzeko.\n2. Joan \"Aplikazio\" (Chrome, Edge, Brave, etab.) edo \"Biltegiratzea\" (Firefox, Palemoon, etab.)\n3. Joan \"Cookieak\" atalera eta gero \"https://accounts.spotify.com\" azpiatalera", + "step_3": "3. pausua", + "step_3_steps": "Kopiatu \"sp_dc\" cookiearen balioa", + "success_emoji": "Eginda! 🥳", + "success_message": "Ongi hasi duzu zure Spotify kontua. Lan bikaina, lagun!", + "step_4": "4. pausua", + "step_4_steps": "Itsatsi \"sp_dc\"-tik kopiatutako balioa", + "something_went_wrong": "Zerbaitek huts egin du", + "piped_instance": "Piped zerbitzariaren instantzia", + "piped_description": "Kanten koizidentzietan erabiltzeko Piped zerbitzariaren instantzia", + "piped_warning": "Batzuk agian ez dute ongi funtzionatuko, zure ardurapean erabili", + "generate_playlist": "Sortu Zerrenda", + "track_exists": "{track} kanta dagoeneko badago", + "replace_downloaded_tracks": "Ordezkatu deskargatutako kanta guztiak", + "skip_download_tracks": "Deskargatutako kanta guztien deskarga baztertu", + "do_you_want_to_replace": "Dagoen kanta ordezkatu nahi duzu??", + "replace": "Ordezkatu", + "skip": "Baztertu", + "select_up_to_count_type": "Aukertu {count} {type}", + "select_genres": "Aukeratu Generoak", + "add_genres": "Gehitu Generoak", + "country": "Herrialdea", + "number_of_tracks_generate": "Sortzeko kanta kopurua", + "acousticness": "Akustikotasuna", + "danceability": "Dantzagarritasuna", + "energy": "Energia", + "instrumentalness": "Instrumentaltasuna", + "liveness": "Zuzenean", + "loudness": "Ozentasuna", + "speechiness": "Hitzaldia", + "valence": "Balentzia", + "popularity": "Populartasuna", + "key": "Tonua", + "duration": "Iraupena (s)", + "tempo": "Tenpoa (BPM)", + "mode": "Modua", + "time_signature": "Konpasa", + "short": "Motza", + "medium": "Ertaina", + "long": "Luzea", + "min": "Min.", + "max": "Max.", + "target": "Helburua", + "moderate": "Moderatua", + "deselect_all": "Desaukeratu dena", + "select_all": "Aukeratu dena", + "are_you_sure": "Ziur zaude?", + "generating_playlist": "Zure pertsonalizatutako zerrenda sortzen...", + "selected_count_tracks": "{count} kanta aukeratuta", + "download_warning": "Abesti guztiak aldi berean deskargatuz gero, argi dago musika pirateatzen ari zarela eta musikaren gizarte sortzaileari kalte egiten diozula. Honen jakitun izan eta artisten lan gogorra errespetatu eta babestea espero dut", + "download_ip_ban_warning": "Bidenabar, baliteke zure IPa YouTuben blokeatzea deskarga eskera gehiegi egiten badituzu. IPa blokeatzeak esan nahi du ezin izango duzula YouTube erabili (nahiz eta saioa hasia izan) gutxienez 2-3 hilabetez IP helbide horretatik. Eta Spotube ez da erantzule izango hori gertatzen bazaizu", + "by_clicking_accept_terms": "'Onartu' klikatzean, ondorengo baldintzak onartzen dituzu:", + "download_agreement_1": "Badakit musika pirateatzen ari naizela. Gaiztoa naiz", + "download_agreement_2": "Ahal dudanean lagunduko diot artistari baina oraingoz ez dut bere artea erosteko dirurik", + "download_agreement_3": "Erabat jakitun naiz YouTubek nire IPa blokea dezakeela eta ez diot Spotube-ri edo bere jabe/laguntzaileei erantzukizunik eskatuko nire oraingo jokaerak ekar ditzakeen arazoengatik", + "decline": "Baztertu", + "accept": "Onartu", + "details": "Xehetasunak", + "youtube": "YouTube", + "channel": "Kanala", + "likes": "Gustukoak", + "dislikes": "Ez gustukoak", + "views": "Ikuspenak", + "streamUrl": "Streaming-aren URLa", + "stop": "Gelditu", + "sort_newest": "Ordenatu gehitu berrienetik", + "sort_oldest": "Ordenatu gehitu zaharrenetik", + "sleep_timer": "Itzaltzeko tenporizadorea", + "mins": "{minutes} minutu", + "hours": "{hours} ordu", + "hour": "{hours} ordu", + "custom_hours": "Ordu pertsonalizatuak", + "logs": "Log-ak", + "developers": "Garatzaileak", + "not_logged_in": "Ez duzu saioa hasi", + "search_mode": "Bilaketa modua", + "audio_source": "Audio Iturria", + "ok": "OK", + "failed_to_encrypt": "Errorea zifratzean", + "encryption_failed_warning": "Spotube-ek zifratzea darabil datuak modu seguruan biltegiratzeko. Baina huts egin du. Hori dela eta, biltegiratzea ez da segurua izango\nLinux erabiltzen ari bazara, ziurtatu edozein sekretu-zerbitzu (gnome-keyring, kde-wallet, keepassxc etab.) instalatuta duzula", + "querying_info": "Informazioa egiaztatzen...", + "piped_api_down": "Piped-en APIa ez dago eskuragarri", + "piped_down_error_instructions": "Piped-en {pipedInstance} instantzia ez dago martxan une honetan\n\nAldatu instantzia edo aldatu 'API mota' YouTuberen API ofizialera\n\nZiurtatu aplikazioa berrabiarazten duzula aldaketa eta gero", + "you_are_offline": "Une honetan konexiorik gabe zaude", + "connection_restored": "Internet konexioa berrezarri egin da", + "use_system_title_bar": "Erabili sistemako izenburu barra", + "crunching_results": "Emaitzak prozesatzen...", + "search_to_get_results": "Bilatu emaitzak lortzeko", + "use_amoled_mode": "Erabili AMOLED modua", + "pitch_dark_theme": "Dart-en gai iluna", + "normalize_audio": "Normalizatu audioa", + "change_cover": "Aldatu azala", + "add_cover": "Gehitu azala", + "restore_defaults": "Berrezarri berezko balioak", + "download_music_codec": "Deskargatutako musikaren codec-a", + "streaming_music_codec": "Streaming musikaren codec-a", + "login_with_lastfm": "Hasi saioa Last.fm-n", + "connect": "Konektatu", + "disconnect_lastfm": "Deskonektatu Last.fm-tik", + "disconnect": "Deskonektatu", + "username": "Erabiltzaile izena", + "password": "Pasahitza", + "login": "Hasi saioa", + "login_with_your_lastfm": "Hasi saioa Last.fm-ko zure kontuarekin", + "scrobble_to_lastfm": "Scrobble Last.fm-ra", + "go_to_album": "Albumera joan", + "discord_rich_presence": "Discord-en presentzia aberatsa", + "browse_all": "Esploratu dena", + "genres": "Generoak", + "explore_genres": "Esploratu generoak", + "friends": "Lagunak", + "no_lyrics_available": "Sentitzen dut, ezin dira kanta honen hitzak aurkitu", + "start_a_radio": "Hasi Irrati bat", + "how_to_start_radio": "Nola hasi nahi duzu irratia?", + "replace_queue_question": "Uneko zerrenda ordezkatu nahi duzu edo bertan gehitu?", + "endless_playback": "Amaigabeko erreprodukzioa", + "delete_playlist": "Ezabatu zerrenda", + "delete_playlist_confirmation": "Ziur zaude zerrenda ezabatu nahi duzula?", + "local_tracks": "Kanta lokalak", + "song_link": "Kantaren lotura", + "skip_this_nonsense": "Utzi txorakeria hau", + "freedom_of_music": "“Musika Askatasuna”", + "freedom_of_music_palm": "“Musika Askatasuna zure eskuetan”", + "get_started": "Has gaitezen", + "youtube_source_description": "Gomendatua eta hobekien dabilena.", + "piped_source_description": "Aske zara? YouTube bezala, baino askeago.", + "jiosaavn_source_description": "Asia hegoaldeko herrialdeetarako hoberena.", + "highest_quality": "Kalitate Onena: {quality}", + "select_audio_source": "Aukeratu Audio Iturria", + "endless_playback_description": "Gehitu automatikoki kanta berriak\n ilararen bukaeran", + "choose_your_region": "Aukeratu zure herrialdea", + "choose_your_region_description": "Honekin Spotube-k zure kokalerakuari dagokion edukia\neskeiniko dizu.", + "choose_your_language": "Aukeratu zure hizkuntza", + "help_project_grow": "Lagundu proiektu honi hazten", + "help_project_grow_description": "Spotube kode irekiko proiektu bat da. Proiektu hau hazten lagundu dezakezu, erroreak jakinaraziz edo ezaugarri berriak proposatuz.", + "contribute_on_github": "GitHub-en lagundu", + "donate_on_open_collective": "Open Collective-en diruz lagundu", + "browse_anonymously": "Nabigatu Anonimoki", + "enable_connect": "Gaitu konexioa", + "enable_connect_description": "Kontrolatu Spotube beste gailu batzuetatik", + "devices": "Gailuak", + "select": "Aukeratu", + "connect_client_alert": "{client} gailuak kontrolatzen zaitu", + "this_device": "Gailu hau", + "remote": "Urrunekoa" +} \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index ef3685fa..29ededde 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -43,5 +43,6 @@ class L10n { const Locale('tr', 'TR'), const Locale('zh', 'CN'), const Locale('vi', 'VN'), + const Locale('eu', 'ES'), ]; } From 1e7f0e1fe71e0a8d86614fc884861f8791469112 Mon Sep 17 00:00:00 2001 From: Omari Sopromadze Date: Fri, 10 May 2024 18:37:22 +0200 Subject: [PATCH 074/261] feat(translations): add georgian language (#1450) * feat: add georgian language * feat: translate more georgian words --- lib/collections/language_codes.dart | 8 +- lib/components/home/sections/genres.dart | 2 +- lib/l10n/app_ka.arb | 324 +++++++++++++++++++++++ lib/l10n/l10n.dart | 1 + 4 files changed, 330 insertions(+), 5 deletions(-) create mode 100644 lib/l10n/app_ka.arb diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index dcc42657..ae75433a 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -213,10 +213,10 @@ abstract class LanguageLocals { // name: "Galician", // nativeName: "Galego", // ), - // "ka": const ISOLanguageName( - // name: "Georgian", - // nativeName: "ქართული", - // ), + "ka": const ISOLanguageName( + name: "Georgian", + nativeName: "ქართული", + ), "de": const ISOLanguageName( name: "German", nativeName: "Deutsch", diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index ac2644f0..8fbc8bf9 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -54,7 +54,7 @@ class HomeGenresSection extends HookConsumerWidget { }, icon: const Icon(SpotubeIcons.angleRight), label: Text( - "Browse All", + context.l10n.browse_all, style: textTheme.bodyMedium?.copyWith( color: colorScheme.secondary, ), diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb new file mode 100644 index 00000000..3da06444 --- /dev/null +++ b/lib/l10n/app_ka.arb @@ -0,0 +1,324 @@ +{ + "guest": "სტუმარი", + "browse": "ნახვა", + "search": "ძებნა", + "library": "ბიბლიოთეკა", + "lyrics": "ტექსტები", + "settings": "კონფიგურაციები", + "genre_categories_filter": "კატეგორიების ან ჟანრების ფილტრი...", + "genre": "ჟანრი", + "personalized": "პეერსონალიზებული", + "featured": "გამორჩეული", + "new_releases": "ახალი გამოცემები", + "songs": "სიმღერები", + "playing_track": "უკრავს {track}", + "queue_clear_alert": "ეს გაასუფთავებს მიმდინარე რიგს. {track_length} ტრეკი წაიშლება\nᲒინდა გააგრძელო?", + "load_more": "მეტის ჩატვირთვა", + "playlists": "ფლეილისტები", + "artists": "არტისტები", + "albums": "ალბომები", + "tracks": "ტრეკები", + "downloads": "ჩამოტვირთვები", + "filter_playlists": "ფლეილისტების გაფილტვრა...", + "liked_tracks": "მოწონებული ტრეკები", + "liked_tracks_description": "ყველა შენი მოწონებული ტრეკი", + "create_playlist": "ფლეილისტის შექმნა", + "create_a_playlist": "ფლეილისტის შექმნა", + "update_playlist": "ფლეილისტის განახლება", + "create": "შექმნა", + "cancel": "გაუქმება", + "update": "განახლება", + "playlist_name": "ფლეილისტის სახელი", + "name_of_playlist": "ფლეილისტის სახელი", + "description": "აღწერა", + "public": "საჯარო", + "collaborative": "კოლაბორაციული", + "search_local_tracks": "ლოცალური ტრეკების ძებნა...", + "play": "დაკვრა", + "delete": "წაშლა", + "none": "არცერთი", + "sort_a_z": "დალაგება A-Z-ს მიხედვით", + "sort_z_a": "დალაგება Z-A-ს მიხედვით", + "sort_artist": "დალაგება არტისტის მიხედვით", + "sort_album": "დალაგება ალბომის მიხედვით", + "sort_duration": "დალაგება ხანგრძლივობის მიხედვით", + "sort_tracks": "ტრეკების დალაგება", + "currently_downloading": "მიმდინარეობს ჩამოტვირთვა ({tracks_length})", + "cancel_all": "ყველას გაუქმება", + "filter_artist": "არტისტების ფილტრი...", + "followers": "{followers} ფოლოვერები", + "add_artist_to_blacklist": "არტისტის შავ სიაში დამატება", + "top_tracks": "ტოპ ტრეკები", + "fans_also_like": "ფანებს ასევე მოსწონთ", + "loading": "იტვირთება...", + "artist": "არტისტი", + "blacklisted": "შავ სიაში მყოფი", + "following": "ფოლოვინგი", + "follow": "დაფოლოვება", + "artist_url_copied": "არტისტის ლინკი დაკოპირებულია", + "added_to_queue": "{tracks} ტრეკი დაემატა რიგში", + "filter_albums": "ალბომების გაფილტვრა...", + "synced": "სინქრონიზებული", + "plain": "Plain", + "shuffle": "რიგის არევა", + "search_tracks": "ტრეკების ძებნა...", + "released": "გამოშვებული", + "error": "შეცდომა {error}", + "title": "სათაური", + "time": "დრო", + "more_actions": "მეტი მოქმედებები", + "download_count": "გადმოწერა ({count})", + "add_count_to_playlist": "ფლეილისტში ({count})-ის დამატება", + "add_count_to_queue": "რიგში ({count})-ის დამატება", + "play_count_next": "შემდეგი ({count})-ის დაკვრა", + "album": "ალბომი", + "copied_to_clipboard": "{data} დაკოპირებულია", + "add_to_following_playlists": "დაამატე {track} ამ ფლეილისტებში", + "add": "დამატება", + "added_track_to_queue": "რიგში დაემატა {track}", + "add_to_queue": "რიგში დამატება", + "track_will_play_next": "{track} დაუკრავს შემდეგს", + "play_next": "შემდეგის დაკვრა", + "removed_track_from_queue": "რიგიდან წაიშალა {track}", + "remove_from_queue": "რიგიდან წაშლა", + "remove_from_favorites": "ფავორიტებიდან წაშლა", + "save_as_favorite": "ფავორიტებში დამატება", + "add_to_playlist": "ფლეილისტში დამატება", + "remove_from_playlist": "ფლეილისტიდან წაშლა", + "add_to_blacklist": "შავ სიაში დამატება", + "remove_from_blacklist": "შავი სიიდან წაშლა", + "share": "გაზიარება", + "mini_player": "მინი დამკვრელი", + "slide_to_seek": "გადახვევისთვის გაასრიალეთ წინ ან უკან", + "shuffle_playlist": "ფლეილისტის არევა", + "unshuffle_playlist": "ფლეილისტის დალაგება", + "previous_track": "წინა ტრეკი", + "next_track": "შემდეგი ტრეკი", + "pause_playback": "დაკვრის გაჩერება", + "resume_playback": "დაკვრის გაგრძელება", + "loop_track": "ტრეკის ლუპზე დაკვრა", + "repeat_playlist": "ფლეილისტის გამეორება", + "queue": "რიგი", + "alternative_track_sources": "ალტერნატიული ტრეკების წყაროები", + "download_track": "გადმოწერე ტრეკი", + "tracks_in_queue": "{tracks} ტრეკი რიგში", + "clear_all": "ყველას წაშლა", + "show_hide_ui_on_hover": "UI-ის ჩვენება/დამალვა ჰოვერზე", + "always_on_top": "ტოველთვის ზემოდან", + "exit_mini_player": "მინი დამკვრელიდან გამოსვლა", + "download_location": "ჩამოტვირთვის მდებარეობა", + "account": "ანგარიში", + "login_with_spotify": "შედით თქვენი Spotify ანგარიშით", + "connect_with_spotify": "დაუკავშირდით Spotify-ს", + "logout": "გასვლა", + "logout_of_this_account": "ანგარიშიდან გასვლა", + "language_region": "ენა და რეგიონი", + "language": "ენა", + "system_default": "სისტემის ნაგულისხმევი", + "market_place_region": "მარკეტფლეისის რეგიონი", + "recommendation_country": "რეკომენდირებული ქვეყანა", + "appearance": "გარეგნობა", + "layout_mode": "განლაგების რეჟიმი", + "override_layout_settings": "რესფონსივ განლაგების რეჟიმის კონფიგურაციაზე გადაწერა", + "adaptive": "ადაპტირებული", + "compact": "კომპაქტური", + "extended": "გაფართოებული", + "theme": "თემა", + "dark": "ბნელი", + "light": "ღია", + "system": "სისტემის", + "accent_color": "აქცენტის ფერი", + "sync_album_color": "ალბომის ფერის სინქრონიზაცია", + "sync_album_color_description": "დომინანტური ალბომის ფერის აქცენტის ფერად გამოყენება", + "playback": "დაკვრა", + "audio_quality": "აუდიოს ხარისხი", + "high": "მაღალი", + "low": "დაბალი", + "pre_download_play": "წინასწარ ჩამოტვირთვა და დაკვრა", + "pre_download_play_description": "აუდიოს სტრიმინგის ნაცვლად, ბაიტების ჩამოტვირთვა და დაკვრა (რეკომენდებულია უფრო მაღალი გამტარუნარიანობის მომხმარებლებისთვის)", + "skip_non_music": "არა მუსიკალური ნაწილის გამოტოვება (სპონსორის ბლოკი)", + "blacklist_description": "შავ სიაში მყოფი არტისტები და ტრეკები", + "wait_for_download_to_finish": "გთხოვთ, დაელოდოთ მიმდინარე ჩამოტვირთვის დასრულებას", + "desktop": "დესკტოპი", + "close_behavior": "დახურვის ქცევა", + "close": "დახურვა", + "minimize_to_tray": "მინიმიზაცია", + "show_tray_icon": "სისტემის აიკონის ჩვენება", + "about": "ჩვენს შესახებ", + "u_love_spotube": "We know you love Spotube", + "check_for_updates": "განახლებების შემოწმება", + "about_spotube": "Spotube-ს შესახებ", + "blacklist": "შავი სია", + "please_sponsor": "გთხოვთ დაგვასპონსოროთ", + "spotube_description": "Spotube, a lightweight, cross-platform, free-for-all spotify client", + "version": "ვერსია", + "build_number": "Build Number", + "founder": "დამფუძნებელი", + "repository": "რეპოზიტორია", + "bug_issues": "Bug+Issues", + "made_with": "Made with ❤️ in Bangladesh🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "ლიცენზია", + "add_spotify_credentials": "დასაწყებად დაამატეთ თქვენი Spotify მონაცემები", + "credentials_will_not_be_shared_disclaimer": "არ ინერვიულოთ, თქვენი მონაცემები არ იქნება შეგროვებული ან გაზიარებული ვინმესთან", + "know_how_to_login": "არ იცით როგორ გააკეთოთ ეს?", + "follow_step_by_step_guide": "მიჰყევით ნაბიჯ-ნაბიჯ სახელმძღვანელოს", + "spotify_cookie": "Spotify {name} ქუქი", + "cookie_name_cookie": "{name} ქუქი", + "fill_in_all_fields": "გთხოვთ შეავსოთ ყველა ველი", + "submit": "გაგზავნა", + "exit": "გამოსვლა", + "previous": "წინა", + "next": "შემდეგი", + "done": "მზადაა", + "step_1": "ნაბიჯი 1", + "first_go_to": "პირველი, გადადით", + "login_if_not_logged_in": "და შესვლა/რეგისტრაცია, თუ არ ხართ შესული", + "step_2": "ნაბიჯი 2", + "step_2_steps": "1. როცა შეხვალთ, დააჭირეთ F12-ს ან მაუსის მარჯვენა ღილაკს > Inspect to Open the Browser devtools.\n2. შემდეგ გახსენით \"Application\" განყოფილება (Chrome, Edge, Brave etc..) ან \"Storage\" განყოფილება (Firefox, Palemoon etc..)\n3. შედით \"Cookies\" სექციაში და შემდეგ \"https://accounts.spotify.com\" სუბსექციაში", + "step_3": "ნაბიჯი 3", + "step_3_steps": "დააკოპირეთ \"sp_dc\" ქუქი-ფაილის მნიშვნელობა", + "success_emoji": "წარმატება🥳", + "success_message": "თქვენ წარმატებით შეხვედით თქვენი Spotify ანგარიშით.", + "step_4": "ნაბიჯი 4", + "step_4_steps": "ჩასვით კოპირებული \"sp_dc\" მნიშვნელობა", + "something_went_wrong": "Რაღაც არასწორად წავიდა", + "piped_instance": "Piped Server Instance", + "piped_description": "The Piped server instance to use for track matching", + "piped_warning": "ზოგიერთი მათგანმა შეიძლება კარგად არ იმუშაოს. ", + "generate_playlist": "ფლეილისტის დაგენერირება", + "track_exists": "ტრეკი {track} უკვე არსებობს", + "replace_downloaded_tracks": "ყველა ჩამოტვირთული ტრეკის შეცვლა", + "skip_download_tracks": "ყველა ჩამოტვირთული ტრეკის გამოტოვება", + "do_you_want_to_replace": "გსურთ შეცვალოთ არსებული ტრეკი??", + "replace": "შეცვლა", + "skip": "გამოტოვება", + "select_up_to_count_type": "აირჩიე {count}-მდე {type}", + "select_genres": "ჟანრების არჩევა", + "add_genres": "ჟანრების დამატება", + "country": "ქვეყანა", + "number_of_tracks_generate": "დასაგენერირებელი ტრეკების რაოდენობა", + "acousticness": "Acousticness", + "danceability": "Danceability", + "energy": "Energy", + "instrumentalness": "Instrumentalness", + "liveness": "Liveness", + "loudness": "Loudness", + "speechiness": "Speechiness", + "valence": "Valence", + "popularity": "Popularity", + "key": "Key", + "duration": "Duration (s)", + "tempo": "Tempo (BPM)", + "mode": "Mode", + "time_signature": "Time Signature", + "short": "Short", + "medium": "საშუალო", + "long": "გრძელი", + "min": "მინიმალური", + "max": "მაქსიმალური", + "target": "სამიზნე", + "moderate": "საშუალო", + "deselect_all": "ყველა მონიშვნის გაუქმება", + "select_all": "ყველას მონიშვნა", + "are_you_sure": "Დარწმუნებული ხართ?", + "generating_playlist": "მიმდინარეობს თქვენი მორგებული ფლეილისტის გენერირება...", + "selected_count_tracks": "არჩეულია {count} ტრეკი", + "download_warning": "If you download all Tracks at bulk you're clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist's hard work", + "download_ip_ban_warning": "BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can't use YouTube (even if you're logged in) for at least 2-3 months from that IP device. And Spotube doesn't hold any responsibility if this ever happens", + "by_clicking_accept_terms": "By clicking 'accept' you agree to following terms:", + "download_agreement_1": "I know I'm pirating Music. I'm bad", + "download_agreement_2": "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art", + "download_agreement_3": "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action", + "decline": "უარყოფა", + "accept": "დათანხმება", + "details": "დეტალები", + "youtube": "YouTube", + "channel": "Channel", + "likes": "მოწონებები", + "dislikes": "არ მოწონებები", + "views": "ნახვები", + "streamUrl": "სტრიმის ლინკი", + "stop": "გაჩერება", + "sort_newest": "ფალაგება სიახლის მიხედიტ", + "sort_oldest": "დალაგება სიძველის მიხედვით", + "sleep_timer": "ძილის ტაიმერი", + "mins": "{minutes} წუთი", + "hours": "{hours} საათი", + "hour": "{hours} საათი", + "custom_hours": "მორგებული საათები", + "logs": "ლოგები", + "developers": "დეველოპერები", + "not_logged_in": "არ ხარ დალოგინებული", + "search_mode": "ძებნის რეჟიმი", + "audio_source": "აუდიოს წყარო", + "ok": "ოკ", + "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", + "querying_info": "Querying info...", + "piped_api_down": "Piped API is down", + "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change", + "you_are_offline": "ამჟამად ხაზგარეშე ხართ", + "connection_restored": "თქვენი ინტერნეტ კავშირი აღდგა", + "use_system_title_bar": "სისტემის სათაურის ზოლის გამოყენება", + "crunching_results": "იტვირთება შედეგები...", + "search_to_get_results": "მოძებნეთ შედეგების მისაღებად", + "use_amoled_mode": "Pitch black dark theme", + "pitch_dark_theme": "AMOLED Mode", + "normalize_audio": "აუდიოს ნორმალიზება", + "change_cover": "Ქავერის შეცვლა", + "add_cover": "Ქავერის ფოტოს დამატება", + "restore_defaults": "ნაგულისხმევი პარამეტრების აღდგენა", + "download_music_codec": "მუსიკის კოდეკის გადმოწერა", + "streaming_music_codec": "სტრიმინგ მუსიკის კოდეკი", + "login_with_lastfm": "Last.fm-ით შესვლა", + "connect": "დაკავშირება", + "disconnect_lastfm": "Last.fm-იდან გამოსვლა", + "disconnect": "გამოსვლა", + "username": "მომხმარებელი", + "password": "პაროლი", + "login": "შესვლა", + "login_with_your_lastfm": "Last.fm ანგარიშით შესვლა", + "scrobble_to_lastfm": "Scrobble to Last.fm", + "go_to_album": "ალბომზე გადასვლა", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "ყველას ნახვა", + "genres": "ჟანრები", + "explore_genres": "შეისწავლეთ ჟანრები", + "friends": "მეგობრები", + "no_lyrics_available": "უკაცრავად, ამ ტრეკისთვის ტექსტის პოვნა შეუძლებელია", + "start_a_radio": "რადიოს ჩართვა", + "how_to_start_radio": "როგორ გნებავთ რადიოს ჩართვა?", + "replace_queue_question": "გნებავთ ჩაანაცვლოთ არსებული რიგი თუ დაამატოთ მასზე?", + "endless_playback": "დაუსრულებელი დაკვრა", + "delete_playlist": "ფლეილისტის წაშლა", + "delete_playlist_confirmation": "დარწმუნებული ხართ რომ გნებავთ ფლეილისტის წაშლა?", + "local_tracks": "ლოკალური ტრეკები", + "song_link": "ტრეკის ლინკი", + "skip_this_nonsense": "ამ სისულელის გამოტოვება", + "freedom_of_music": "“მუსიკის თავისუფლება”", + "freedom_of_music_palm": "“მუსიკის თავისუფლება შენს ხელის გულზე”", + "get_started": "დავიწყოთ", + "youtube_source_description": "რეკომენდებულია და მუშაობს საუკეთესოდ.", + "piped_source_description": "თავისუფლად გრძნობთ თავს? იგივეა, რაც YouTube, მაგრამ ბევრი თავისუფალი.", + "jiosaavn_source_description": "საუკეთესოა სამხრეთ აზიის რეგიონისთვის.", + "highest_quality": "საუკეთესო ხარისხი: {quality}", + "select_audio_source": "აუდიოს წყაროს არჩევა", + "endless_playback_description": "ახალი სიმთერების ავტომატურად რიგის ბოლოში დამატება", + "choose_your_region": "აირჩიე შენი რეგიონი", + "choose_your_region_description": "This will help Spotube show you the right content\nfor your location.", + "choose_your_language": "აირჩიე ენა", + "help_project_grow": "დაეხმარეთ ამ პროექტს განვითარებაში", + "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", + "contribute_on_github": "GitHub-ზე კონტრიბუცია", + "donate_on_open_collective": "Open Collective-ზე დონაცია", + "browse_anonymously": "ანონიმურად ნახვა", + "enable_connect": "დაკავშირების ჩართვა", + "enable_connect_description": "აკონტროლე Spotube სხვა მოწყობილობებიდან", + "devices": "მოწყობილობები", + "select": "არჩევა", + "connect_client_alert": "თქვენ კონტროლირებული ხართ {client} მოწყობილობით", + "this_device": "ეს მოწყობილობა", + "remote": "დისტანციური" +} \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 29ededde..7d1e995b 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -33,6 +33,7 @@ class L10n { const Locale('hi', 'IN'), const Locale('it', 'IT'), const Locale('ja', 'JP'), + const Locale('ka', 'GE'), const Locale('ko', 'KR'), const Locale('nl', 'NL'), const Locale('pl', 'PL'), From edc997e7470ce17f60c96b8198dc8851cbf21f18 Mon Sep 17 00:00:00 2001 From: ctih <78687256+ctih1@users.noreply.github.com> Date: Fri, 10 May 2024 19:49:38 +0300 Subject: [PATCH 075/261] feat(translations): add Finnish translations (#1449) * docs: broken link in README.md (fixes #1310) (#1311) * docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. * added finnish translation * chore: fix arb syntax errors and language in l10n entries --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho Co-authored-by: Onni Nevala --- lib/collections/language_codes.dart | 8 +- lib/l10n/app_fi.arb | 324 ++++++++++++++++++++++++++++ lib/l10n/l10n.dart | 1 + 3 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 lib/l10n/app_fi.arb diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index ae75433a..099b1a6e 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -197,10 +197,10 @@ abstract class LanguageLocals { // name: "Fijian", // nativeName: "vosa Vakaviti", // ), - // "fi": const ISOLanguageName( - // name: "Finnish", - // nativeName: "suomi", - // ), + "fi": const ISOLanguageName( + name: "Finnish", + nativeName: "suomi", + ), "fr": const ISOLanguageName( name: "French", nativeName: "français", diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb new file mode 100644 index 00000000..35470791 --- /dev/null +++ b/lib/l10n/app_fi.arb @@ -0,0 +1,324 @@ +{ + "guest": "Vieras", + "browse": "Selaa", + "search": "Hae", + "library": "Kirjasto", + "lyrics": "Lyriikat", + "settings": "Asetukset", + "genre_categories_filter": "Suodata kategorioita tai genrejä", + "genre": "Genre", + "personalized": "Personoidut", + "featured": "Esittelyssä", + "new_releases": "Uusi julkaisu", + "songs": "Laulut", + "playing_track": "Soitetaan {track}", + "queue_clear_alert": "Tämä tulee tyhjentämään jonon. {track_length} Kappaleita poistetaan\nHaluatko jatkaa?", + "load_more": "Lataa lisää", + "playlists": "Soittolistat", + "artists": "Artistit", + "albums": "Albumit", + "tracks": "Kappaleet", + "downloads": "Lataukset", + "filter_playlists": "Suodata soittolistasi...", + "liked_tracks": "Tykätyt kappaleet", + "liked_tracks_description": "Kaikki tykättysi kappaleet", + "create_playlist": "Luo soittolista", + "create_a_playlist": "Luo soittolista", + "update_playlist": "Päivitä soittolista", + "create": "Luo", + "cancel": "Peruuta", + "update": "Päivitä", + "playlist_name": "Soittolistan nimi", + "name_of_playlist": "Soittolistan nimi", + "description": "Kuvaus", + "public": "Julkinen", + "collaborative": "Collaborative", + "search_local_tracks": "Hae paikallisia lauluja...", + "play": "Soita", + "delete": "Poista", + "none": "Ei mitään", + "sort_a_z": "Suodata A-Z", + "sort_z_a": "Suodata Z-A", + "sort_artist": "Suodata Artistilta", + "sort_album": "Suodata Albumilta", + "sort_duration": "Suodata Pituudelta", + "sort_tracks": "Suodata Kappaleet", + "currently_downloading": "Ladataan ({tracks_length})", + "cancel_all": "Peru kaikki", + "filter_artist": "Suodata artistit...", + "followers": "{followers} Seuraajaa", + "add_artist_to_blacklist": "Lisää artisti mustalle listalle", + "top_tracks": "Suosituimmat kappaleet", + "fans_also_like": "Fanit myös tykkäsivät", + "loading": "Ladataan...", + "artist": "Artisti", + "blacklisted": "Mustalistattu", + "following": "Seurataan", + "follow": "Seuraa", + "artist_url_copied": "Aristin URL kopioitiin leikepöytään", + "added_to_queue": "Lisättiin {tracks} kappaletta jonoon", + "filter_albums": "Suodata albumit...", + "synced": "Synkronoitu", + "plain": "Tavallinen", + "shuffle": "Sekoita", + "search_tracks": "Hae kappaleita...", + "released": "Julkaistu", + "error": "Virhe {error}", + "title": "Otsikko", + "time": "Aika", + "more_actions": "Lisää toimintoja", + "download_count": "Lataa ({count})", + "add_count_to_playlist": "Lisää ({count}) Soittolistaasi", + "add_count_to_queue": "Lisää ({count}) Jonoon", + "play_count_next": "Soita ({count}) seuraavaksi", + "album": "Albumi", + "copied_to_clipboard": "Kopioitiin {data} leikepöytään", + "add_to_following_playlists": "Lisää {track} seuraaviin soittolistoihin", + "add": "Lisää", + "added_track_to_queue": "Lisättiin {track} jonoon", + "add_to_queue": "Lisää jonoon", + "track_will_play_next": "{track} Soitetaan seuraavaksi", + "play_next": "Soita seuraavaksi", + "removed_track_from_queue": "Poistettiin {track} jonosta", + "remove_from_queue": "Poista jonosta", + "remove_from_favorites": "Poista suosikeista", + "save_as_favorite": "Tallenna soittolistana", + "add_to_playlist": "Lisää soittolistaan", + "remove_from_playlist": "Poista soittolistasta", + "add_to_blacklist": "Lisää mustalle listalle", + "remove_from_blacklist": "Poista mustalistalta", + "share": "Jaa", + "mini_player": "Minisoitin", + "slide_to_seek": "Liu'uta mennäkseen eteenpäin tai taaksepäin", + "shuffle_playlist": "Sekoita soittolista", + "unshuffle_playlist": "Poista sekoitus soittolistasta", + "previous_track": "Äskeinen kappale", + "next_track": "Seuraava kappale", + "pause_playback": "Pysäytä soittolistan toisto", + "resume_playback": "Jatka soittolistan toistoa", + "loop_track": "Uudelleentoista kappale", + "repeat_playlist": "Toista soittolista uudelleen", + "queue": "Jono", + "alternative_track_sources": "Toinen kappale lähde", + "download_track": "Lataa kappale", + "tracks_in_queue": "{tracks} kappaletta jonossa", + "clear_all": "Tyhjennä kaikki", + "show_hide_ui_on_hover": "Näytä/Piilota UI leijumalla", + "always_on_top": "Aina päällimmäisenä", + "exit_mini_player": "Lähde minisoittimesta", + "download_location": "Lataus sijainti", + "account": "Käyttäjä", + "login_with_spotify": "Kirjaudu Spotify-käyttäjällä", + "connect_with_spotify": "Yhdistä Spotify:lla", + "logout": "Kirjaudu ulos", + "logout_of_this_account": "Kirjaudu ulos tältä käyttäjältä", + "language_region": "Kieli ja Maa", + "language": "Kieli", + "system_default": "Järjestelmän oletus", + "market_place_region": "Markkina-alue", + "recommendation_country": "Suositeltu maa", + "appearance": "Ulkomuto", + "layout_mode": "Asettelutila", + "override_layout_settings": "Jätä reagoiva asettelutila huomioimatta", + "adaptive": "Mukautuva", + "compact": "Kompakti", + "extended": "Laajennettu", + "theme": "Teema", + "dark": "Tumma", + "light": "Vaalea", + "system": "Järjestelmä", + "accent_color": "Korostusväri", + "sync_album_color": "Synkronoi albumin väri", + "sync_album_color_description": "Käyttää albumin kansitaiteen vallitsevaa väirä korostuvärinä", + "playback": "Toisto", + "audio_quality": "Äänenlaatu", + "high": "Korkea", + "low": "Matala", + "pre_download_play": "Esilataa ja soita", + "pre_download_play_description": "Audion suoratoiston sijaan, lataa tavut ja soita ne (Suositeltu korkeamman kaistanleveyden käyttäjille)", + "skip_non_music": "Ohita ei-musiikki kohdat (SponsorBlock)", + "blacklist_description": "Mustalistat kappaleet aja artistit", + "wait_for_download_to_finish": "Odota nykyisen latauksen lopetteluun", + "desktop": "Työpöytä", + "close_behavior": "Sulkemisen käyttäytyminen", + "close": "Sulje", + "minimize_to_tray": "Minimisoi tehtäväpalkkiin", + "show_tray_icon": "Näytä järjestelmäkuvake", + "about": "Tietoa", + "u_love_spotube": "Tiedämme että rakastat Spotubea", + "check_for_updates": "Tarkista päivitykset", + "about_spotube": "Tietoa Spotube:sta", + "blacklist": "Mustalista", + "please_sponsor": "Sponsoroi/Lahjoita, kiitos", + "spotube_description": "Spotube, kevyt, cross-platform, vapaa-kaikille spotify clientti", + "version": "Versio", + "build_number": "Rakennusnumero", + "founder": "Perustaja", + "repository": "Arkisto", + "bug_issues": "Bugit+Ongelmat", + "made_with": "Tehty ❤️ Bangladeshista 🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Lisenssi", + "add_spotify_credentials": "Lisää Spotify-tunnuksesi aloittaaksesi", + "credentials_will_not_be_shared_disclaimer": "Älä huoli, tunnuksiasi ei talleteta tai jaeta kenenkään kanssa", + "know_how_to_login": "Etkö tiedä miten tehdä tämä?", + "follow_step_by_step_guide": "Seuraa askel askeleelta opasta", + "spotify_cookie": "Spotify {name} Keksi", + "cookie_name_cookie": "{name} Keksi", + "fill_in_all_fields": "Täytä kaikki kentät", + "submit": "Lähetä", + "exit": "Poistu", + "previous": "Edellinen", + "next": "Seuraava", + "done": "Tehty", + "step_1": "Vaihe 1", + "first_go_to": "Ensiksi, mene", + "login_if_not_logged_in": "ja Kirjaudu/Tee tili jos et ole kirjautunut sisään", + "step_2": "Vaihe 2", + "step_2_steps": "1. Kun olet kirjautunut, paina F12 tai oikeaa hiiren näppäintä > Tarkista ja avaa selaimen kehittäjä työkalut.\n2. Mene sitten \"Application\"-välilehteen (Chrome, Edge, Brave jne..) tai \"Storage\"-välilehteen (Firefox, Palemoon jne..)\n3. Mene \"Cookies\"-osastoon, sitten \"https://accounts.spotify.com\" alakohtaan.", + "step_3": "Vaihe 3", + "step_3_steps": "Kopioi Keksin \"sp_dc\" arvo", + "success_emoji": "Onnistuit🥳", + "success_message": "Olet nyt kirjautunut sisään Spotify-käyttäjällesi. Hyvää työtä toveri!", + "step_4": "Vaihe 4", + "step_4_steps": "Liitä kopioitu \"sp_dc\" arvo", + "something_went_wrong": "Jotain meni pieleen", + "piped_instance": "Johdettu palvelinesiintymä", + "piped_description": "Johdettu palvelinesiintymä Kappale täsmäyksiin", + "piped_warning": "Jotkut niistä eivät toimi hyvin, käytä siis omalla vastuullasi", + "generate_playlist": "Tuota soittolista", + "track_exists": "Kappale {track} on jo olemassa!", + "replace_downloaded_tracks": "Korvaa kaikki ladatut kappaleet", + "skip_download_tracks": "Ohita ladattujen laulujen lataaminen", + "do_you_want_to_replace": "Haluatko korvata olemassa olevan kappaleen??", + "replace": "Korvaa", + "skip": "Ohita", + "select_up_to_count_type": "Valitse enintään {count} {type}", + "select_genres": "Valitse Genret", + "add_genres": "Lisää Genrejä", + "country": "Maa", + "number_of_tracks_generate": "Numero tuotettavia kappaleita", + "acousticness": "Akustisuus", + "danceability": "Tanssittavuus", + "energy": "Energia", + "instrumentalness": "Instrumentaalisuus", + "liveness": "Elävyyttä", + "loudness": "Äänekkyys", + "speechiness": "Puheisuus", + "valence": "Valenssi", + "popularity": "Suosio", + "key": "Sävellaji", + "duration": "Pituus (s)", + "tempo": "Tempo (BPM)", + "mode": "Tila", + "time_signature": "Aikamerkki", + "short": "Lyhyt", + "medium": "Keskikokoinen", + "long": "Pitkä", + "min": "Minimi", + "max": "Maximi", + "target": "Kohde", + "moderate": "Kohtalainen", + "deselect_all": "Poista kaikki valinnat", + "select_all": "Valitse kaikki", + "are_you_sure": "Oletko varma?", + "generating_playlist": "Luodaan mukautettua soittolistoa...", + "selected_count_tracks": "Valittu {count} kappaletta", + "download_warning": "Jos lataat kaikki laulut kerrällä olet selkeästi Piratoimassa ja aiheuttamassa vahinkoa musiikin luovaan yhteiskuntaan. Toivottavasti olet tietoinen tästä. Yritä aina kunnioittaa ja tukea Artistin kovaa työtä.", + "download_ip_ban_warning": "BTW, YouTube voi estää IP-Osoitteesi tavallista liiallisten latauspyyntöjen takia. IP-Osoitteen esto tarkoittaa sitä, ettet voi käyttää YouTubea (vaikka olisit kirjautunut) vähintään 2-3kk aikana kyseiseltä laitteelta. Spotube ei kanna yhtään vastuuta jos se tapahtuu.", + "by_clicking_accept_terms": "Painamalla 'hyväksy' hyväksyt seuraaviin ehtoihin:", + "download_agreement_1": "Tiedän että Piratoin musiikkia. Olen paha.", + "download_agreement_2": "Tuen Artisteja silloin kun pystyn, ja teen tämän vain koska minulla ei ole rahaa ostaa heidän taidetta", + "download_agreement_3": "Ymmärrän että minun YouTube voi estää IP-Osoitteeni ja en pidä Spotubea tai omistajiinsa/avustajia vastuullisena mistään omista teoistsani", + "decline": "Hylkää", + "accept": "Hyväksy", + "details": "Yksityiskohdat", + "youtube": "YouTube", + "channel": "Kanava", + "likes": "Tykkäykset", + "dislikes": "Epä-tykkäykset", + "views": "Näyttökerrat", + "streamUrl": "Suoratoiston URL", + "stop": "Lopeta", + "sort_newest": "Suodata uusimmista", + "sort_oldest": "Suodata vanhimmista", + "sleep_timer": "Uniajastin", + "mins": "{minutes} Minuuttia", + "hours": "{hours} Tuntia", + "hour": "{hours} Tunti", + "custom_hours": "Mukautetut tunnit", + "logs": "Lokit", + "developers": "Kehittäjät", + "not_logged_in": "Et ole kirjautunut sisään.", + "search_mode": "Hakutila", + "audio_source": "Äänilähde", + "ok": "Ok", + "failed_to_encrypt": "Salaaminen epäonnistui", + "encryption_failed_warning": "Spotube käyttää salausta tallentaakseen tietosi, mutta epäonnistui, joten se palaa epäturvalliseen tallennukseen\nJos käytät Linuxia, varmista että sinulla on turvallisuuspalvelu (gnome-keyring, kde-wallet, keepassxc jne) asennettu", + "querying_info": "Hankitaan tietoa...", + "piped_api_down": "Johdettu palvelinesiintymä on alhaalla", + "piped_down_error_instructions": "Johdettu palvelinesiintymä {pipedInstance} on alhaalla.\n\nVaihda joko ilmeytymä tia vahda 'API tyyppi' YouTuben viralliseen API\n\nKäynnistä sovellus uudestaan vaihdon jälkeen", + "you_are_offline": "Et ole yhdistetty verkkoon", + "connection_restored": "Verkkoyhteys palautettu", + "use_system_title_bar": "Käytä järjestelmäpalkkia", + "crunching_results": "Paloitellaan tuloksia...", + "search_to_get_results": "Hae saadakseen tuloksia", + "use_amoled_mode": "Pilkkopimeä tumma teema", + "pitch_dark_theme": "AMOLED Tila", + "normalize_audio": "Normalisoi audio", + "change_cover": "Vaihda koveri", + "add_cover": "Lisää koveri", + "restore_defaults": "Palauta oletukset", + "download_music_codec": "Ladatun musiikin codefc", + "streaming_music_codec": "Suoratoistetun musiikin codec", + "login_with_lastfm": "Kirjaudu sisään Last.fm:llä", + "connect": "Yhdistä", + "disconnect_lastfm": "Katkaise Last.fm", + "disconnect": "Katkaise", + "username": "Käyttäjänimi", + "password": "Salasana", + "login": "Kirjaudu", + "login_with_your_lastfm": "Kirjaudu Last.fm käyttäjälläsi", + "scrobble_to_lastfm": "Scrobble Last.fm:ään", + "go_to_album": "Mene albumiin", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "Selaa kaikki", + "genres": "Genret", + "explore_genres": "Seikkaile genrejä", + "friends": "Kaverit", + "no_lyrics_available": "Anteeksi, emme löytäneet lyriikoita tälle laululle", + "start_a_radio": "Aloita Radio", + "how_to_start_radio": "Kuinka haluat aloittaa radion?", + "replace_queue_question": "Haluatko korvata nykyisen jonon vai lisätä siihen?", + "endless_playback": "Loputon toisto", + "delete_playlist": "Poista soittolista", + "delete_playlist_confirmation": "Oletko varma että haluat poistaa tämän soittolistan?", + "local_tracks": "Paikalliset kappaleet", + "song_link": "Laulun linkki", + "skip_this_nonsense": "Ohita tämä hölynpöly", + "freedom_of_music": "“Musiikin vapaus”", + "freedom_of_music_palm": "“Musiikin vapaus käsissäsi”", + "get_started": "Aloitetaan", + "youtube_source_description": "Suositeltu ja toimii parhaiten.", + "piped_source_description": "Tuntuuko vapaalta? Sama kuin YouTube mutta paljon vapautta", + "jiosaavn_source_description": "Paras Etelä-Aasian alueelle.", + "highest_quality": "Korkein laatu: {quality}", + "select_audio_source": "Valitse äänilähde", + "endless_playback_description": "Lisää automaattisesti uusia lauluja\njonon perään", + "choose_your_region": "Valitse alueesi", + "choose_your_region_description": "Tämä auttaa Spotube näyttämään sinulle oikeaa sisältöä\nsijaintiasi varten.", + "choose_your_language": "Valitse kielesi", + "help_project_grow": "Auta tätä projektia kasvamaan", + "help_project_grow_description": "Spotube projekti minkä lähdekoodi on julkisesti saatavilla. Voit autta tätä projektia kasvamaan muutoksilla, ilmoittamalla bugeista, tai ehdottamalla uusia ominaisuuksia.", + "contribute_on_github": "Auta GitHub:ssa", + "donate_on_open_collective": "Lahjoita avoimessa kollektiivissa", + "browse_anonymously": "Selaa anonyyminä", + "enable_connect": "Ota käyttöön yhdistäminen", + "enable_connect_description": "Ohjaa Spotubea toiselta laitteelta", + "devices": "Laitteet", + "select": "Valitse", + "connect_client_alert": "{client} ohjaa sinua", + "this_device": "Tämä laite", + "remote": "Etä" +} \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index 7d1e995b..d96a9372 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -28,6 +28,7 @@ class L10n { const Locale('de', 'GE'), const Locale('es', 'ES'), const Locale('fa', 'IR'), + const Locale('fi', 'FI'), const Locale('fr', 'FR'), const Locale('ne', 'NP'), const Locale('hi', 'IN'), From 0280654bb6bad373aee521f5a866228d2d38f038 Mon Sep 17 00:00:00 2001 From: Yusril Rapsanjani Date: Sat, 11 May 2024 00:00:24 +0700 Subject: [PATCH 076/261] feat(translations): add Indonesian translation (#1426) * docs: broken link in README.md (fixes #1310) (#1311) * docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. * Add Indonesia translation --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho --- lib/collections/language_codes.dart | 8 +- lib/l10n/app_id.arb | 324 ++++++++++++++++++++++++++++ lib/l10n/l10n.dart | 1 + 3 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 lib/l10n/app_id.arb diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index 099b1a6e..f46e0efe 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -265,10 +265,10 @@ abstract class LanguageLocals { // name: "Interlingua", // nativeName: "Interlingua", // ), - // "id": const ISOLanguageName( - // name: "Indonesian", - // nativeName: "Bahasa Indonesia", - // ), + "id": const ISOLanguageName( + name: "Indonesian", + nativeName: "Bahasa Indonesia", + ), // "ie": const ISOLanguageName( // name: "Interlingue", // nativeName: "Occidental", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb new file mode 100644 index 00000000..b94cdd28 --- /dev/null +++ b/lib/l10n/app_id.arb @@ -0,0 +1,324 @@ +{ + "guest": "Tamu", + "browse": "Jelajahi", + "search": "Cari", + "library": "Pustaka", + "lyrics": "Lirik", + "settings": "Pengaturan", + "genre_categories_filter": "Urutkan kategori atau genre...", + "genre": "Genre", + "personalized": "Dipersonalisasi", + "featured": "Unggulan", + "new_releases": "Rilis Terbaru", + "songs": "Lagu", + "playing_track": "Memutar {track}", + "queue_clear_alert": "Ini akan menghapus antrian saat ini This will clear the current queue. {track_length} trek akan dihapus\nAnda ingin melanjutkan?", + "load_more": "Lebih Banyak", + "playlists": "Daftar Putar", + "artists": "Artis", + "albums": "Album", + "tracks": "Trek", + "downloads": "Unduhan", + "filter_playlists": "Urutkan daftar putar Anda...", + "liked_tracks": "Lagu Yang Disukai", + "liked_tracks_description": "Semua lagu yang Anda sukai", + "create_playlist": "Buat Daftar Putar", + "create_a_playlist": "Buat daftar putar", + "update_playlist": "Ubah daftar putar", + "create": "Buat", + "cancel": "Batal", + "update": "Ubah", + "playlist_name": "Nama Daftar Putar", + "name_of_playlist": "Nama daftar putar", + "description": "Deskripsi", + "public": "Publik", + "collaborative": "Kolaboratif", + "search_local_tracks": "Cari trek lokal...", + "play": "Putar", + "delete": "Hapus", + "none": "Tidak Ada", + "sort_a_z": "Urutkan berdasarkan A-Z", + "sort_z_a": "Urutkan berdasarkan Z-A", + "sort_artist": "Urutkan berdasarkan Artis", + "sort_album": "Urutkan berdasarkan Album", + "sort_duration": "Urutkan berdasarkan Durasi", + "sort_tracks": "Urutkan trek", + "currently_downloading": "Sedang Mengunduh ({tracks_length})", + "cancel_all": "Batalkan Semua", + "filter_artist": "Urutkan artis...", + "followers": "{followers} Pengikut", + "add_artist_to_blacklist": "Tambah artis ke daftar hitam", + "top_tracks": "Lagu Teratas", + "fans_also_like": "Penggemar juga menyukainya", + "loading": "Memuat...", + "artist": "Artis", + "blacklisted": "Masuk Daftar Hitam", + "following": "Mengikuti", + "follow": "Ikuti", + "artist_url_copied": "URL artis telah disalin", + "added_to_queue": "Menambah trek {tracks} ke antrean", + "filter_albums": "Urutkan album...", + "synced": "Disinkronkan", + "plain": "Normal", + "shuffle": "Acak", + "search_tracks": "Cari trek...", + "released": "Dirilis", + "error": "Kesalahan {error}", + "title": "Judul", + "time": "Waktu", + "more_actions": "Tindakan Lainnya", + "download_count": "Unduhan ({count})", + "add_count_to_playlist": "Menambah ({count}) ke Daftar Putar", + "add_count_to_queue": "Menambah ({count}) ke Antrian", + "play_count_next": "Mainkan ({count}) selanjutnya", + "album": "Album", + "copied_to_clipboard": "{data} telah disalin", + "add_to_following_playlists": "Menambah {track} ke Daftar Putar berikut", + "add": "Tambah", + "added_track_to_queue": "Menambah {track} ke antrian", + "add_to_queue": "Tambah ke antrian", + "track_will_play_next": "{track} akan diputar berikutnya", + "play_next": "Mainkan selanjutnya", + "removed_track_from_queue": "Menghapus {track} dari antrian", + "remove_from_queue": "Hapus dari antrian", + "remove_from_favorites": "Hapus dari favorit", + "save_as_favorite": "Simpan sebagai favorit", + "add_to_playlist": "Tambah ke daftar putar", + "remove_from_playlist": "Hapus dari daftar putar", + "add_to_blacklist": "Tambah ke daftar hitam", + "remove_from_blacklist": "Hapus dari daftar hitam", + "share": "Bagikan", + "mini_player": "Pemutar Mini", + "slide_to_seek": "Geser untuk maju atau mundur", + "shuffle_playlist": "Acak daftar putar", + "unshuffle_playlist": "Batalkan pengacakan daftar putar", + "previous_track": "Lagu sebelumnya", + "next_track": "Lagu berikutnya", + "pause_playback": "Jeda Pemutaran", + "resume_playback": "Lanjutkan Pemutaran", + "loop_track": "Ulangi Pemutaran", + "repeat_playlist": "Ulangi daftar putar", + "queue": "Antrian", + "alternative_track_sources": "Sumber trek alternatif", + "download_track": "Unduh lagu", + "tracks_in_queue": "{tracks} trek dalam antrian", + "clear_all": "Bersihkan semua", + "show_hide_ui_on_hover": "Tampil/Sembunyikan UI saat mengarahkan kursor", + "always_on_top": "Selalu di atas", + "exit_mini_player": "Keluar Pemutar Mini", + "download_location": "Lokasi unduhan", + "account": "Akun", + "login_with_spotify": "Masuk dengan Spotify", + "connect_with_spotify": "Hubungkan dengan Spotify", + "logout": "Keluar", + "logout_of_this_account": "Keluar dari akun", + "language_region": "Bahasa & Wilayah", + "language": "Bahasa", + "system_default": "Bawaan Sistem", + "market_place_region": "Wilayah Pasar", + "recommendation_country": "Negara Rekomendasi", + "appearance": "Tampilan", + "layout_mode": "Mode Tata Letak", + "override_layout_settings": "Ganti pengaturan mode tata letak responsif", + "adaptive": "Adaptif", + "compact": "Ringkas", + "extended": "Diperluas", + "theme": "Tema", + "dark": "Gelap", + "light": "Terang", + "system": "Sistem", + "accent_color": "Warna Aksen", + "sync_album_color": "Sinkronkan warna album", + "sync_album_color_description": "Menggunakan warna dominan sampul album sebagai warna aksen", + "playback": "Pemutaran", + "audio_quality": "Kualitas Suara", + "high": "Tinggi", + "low": "Rendah", + "pre_download_play": "Unduh dan putar", + "pre_download_play_description": "Daripada streaming audio, unduh byte dan mainkan (Direkomendasikan untuk pengguna bandwidth yang lebih tinggi)", + "skip_non_music": "Lewati segmen non-musik (SponsorBlock)", + "blacklist_description": "Lagu dan artis di daftar hitam", + "wait_for_download_to_finish": "Tunggu hingga unduhan saat ini selesai", + "desktop": "Desktop", + "close_behavior": "Tutup Perilaku", + "close": "Tutup", + "minimize_to_tray": "Perkecil ke tray", + "show_tray_icon": "Tampilkan tray ikon sistem", + "about": "Tentang", + "u_love_spotube": "Kami tahu Anda menyukai Spotube", + "check_for_updates": "Periksa pembaruan", + "about_spotube": "Tentang Spotube", + "blacklist": "Daftar Hitam", + "please_sponsor": "Silakan Sponsor/Menyumbang", + "spotube_description": "Spotube, klien Spotify yang ringan, lintas platform, dan gratis untuk semua", + "version": "Versi", + "build_number": "Nomor Pembuatan", + "founder": "Pendiri", + "repository": "Repositori", + "bug_issues": "Bug+Masalah", + "made_with": "Dibuat dengan ❤️ di Bangladesh🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Lisensi", + "add_spotify_credentials": "Tambahkan kredensial Spotify Anda untuk memulai", + "credentials_will_not_be_shared_disclaimer": "Jangan khawatir, kredensial Anda tidak akan dikumpulkan atau dibagikan kepada siapa pun", + "know_how_to_login": "Tidak tahu bagaimana melakukan ini?", + "follow_step_by_step_guide": "Ikuti panduan Langkah demi Langkah", + "spotify_cookie": "Spotify {name} Cookie", + "cookie_name_cookie": "{name} Cookie", + "fill_in_all_fields": "Silakan isi semua kolom", + "submit": "Kirim", + "exit": "Keluar", + "previous": "Sebelumnya", + "next": "Berikutnya", + "done": "Selesai", + "step_1": "Langkah 1", + "first_go_to": "Pertama, Pergi ke", + "login_if_not_logged_in": "dan Masuk/Daftar jika Anda belum masuk", + "step_2": "Langkah 2", + "step_2_steps": "1. Setelah Anda masuk, tekan F12 atau Klik Kanan Mouse > Buka Browser Devtools.\n2. Lalu buka Tab \"Aplikasi\" (Chrome, Edge, Brave, dll.) atau Tab \"Penyimpanan\" (Firefox, Palemoon, dll.)\n3. Buka bagian \"Cookie\" lalu subbagian \"https://accounts.spotify.com\"", + "step_3": "Langkah 3", + "step_3_steps": "Salin nilai Cookie \"sp_dc\" ", + "success_emoji": "Berhasil🥳", + "success_message": "Sekarang Anda telah berhasil Masuk dengan akun Spotify Anda. Kerja bagus, sobat!", + "step_4": "Langkah 4", + "step_4_steps": "Tempel nilai \"sp_dc\" yang disalin", + "something_went_wrong": "Terjadi kesalahan", + "piped_instance": "Piped Server Instance", + "piped_description": "The Piped server instance untuk digunakan sebagai pencocokan trek", + "piped_warning": "Beberapa di antaranya mungkin tidak berfungsi dengan baik. Jadi gunakan dengan risiko Anda sendiri", + "generate_playlist": "Hasilkan Daftar Putar", + "track_exists": "Lagu {track} sudah ada", + "replace_downloaded_tracks": "Ganti semua trek yang diunduh", + "skip_download_tracks": "Lewati pengunduhan semua trek yang diunduh", + "do_you_want_to_replace": "Apakah Anda ingin mengganti track yang ada?", + "replace": "Ganti", + "skip": "Lewati", + "select_up_to_count_type": "Pilih hingga {count} {type}", + "select_genres": "Pilih Genre", + "add_genres": "Tambah Genre", + "country": "Negara", + "number_of_tracks_generate": "Jumlah trek yang akan dihasilkan", + "acousticness": "Akustik", + "danceability": "Menari", + "energy": "Energi", + "instrumentalness": "Instrumentalitas", + "liveness": "Kehidupan", + "loudness": "Kekerasan", + "speechiness": "Berbicara", + "valence": "Valensi", + "popularity": "Popularitas", + "key": "Kunci", + "duration": "Durasi (s)", + "tempo": "Tempo (BPM)", + "mode": "Mode", + "time_signature": "Tanda Tangan Waktu", + "short": "Pendek", + "medium": "Sedang", + "long": "Panjang", + "min": "Minimal", + "max": "Maksimal", + "target": "Target", + "moderate": "Sedang", + "deselect_all": "Batalkan Semua", + "select_all": "Pilih Semua", + "are_you_sure": "Anda yakin?", + "generating_playlist": "Menghasilkan daftar putar khusus Anda...", + "selected_count_tracks": "{count} lagu yang dipilih", + "download_warning": "Jika Anda mengunduh semua Lagu secara massal, Anda jelas membajak Musik & menyebabkan kerusakan pada masyarakat kreatif Musik. Saya harap Anda menyadari hal ini. Selalu berusaha menghormati & mendukung kerja keras Artis", + "download_ip_ban_warning": "BTW, IP Anda bisa diblokir di YouTube karena permintaan unduhan yang berlebihan dari biasanya. Blokir IP berarti Anda tidak dapat menggunakan YouTube (meskipun Anda masuk) setidaknya selama 2-3 bulan dari perangkat IP tersebut. Dan Spotube tidak bertanggung jawab jika hal ini terjadi", + "by_clicking_accept_terms": "Dengan mengklik 'terima' Anda menyetujui ketentuan berikut:", + "download_agreement_1": "Saya tahu saya membajak Musik. Saya buruk", + "download_agreement_2": "Saya akan mendukung Artis di mana pun saya bisa dan saya melakukan ini hanya karena saya tidak punya uang untuk membeli karya seni mereka", + "download_agreement_3": "Saya sepenuhnya menyadari bahwa IP saya dapat diblokir di YouTube & saya tidak menganggap Spotube atau pemilik/kontributornya bertanggung jawab atas kecelakaan apa pun yang disebabkan oleh tindakan saya saat ini", + "decline": "Menolak", + "accept": "Setuju", + "details": "Detail", + "youtube": "YouTube", + "channel": "Channel", + "likes": "Suka", + "dislikes": "Tidak Suka", + "views": "Dilihat", + "streamUrl": "URL Stream", + "stop": "Berhenti", + "sort_newest": "Urutkan yang baru ditambah", + "sort_oldest": "Urutkan yang paling lama ditambah", + "sleep_timer": "Pengatur Waktu Tidur", + "mins": "{minutes} Menit", + "hours": "{hours} Jam", + "hour": "{hours} Jam", + "custom_hours": "Jam Kostum", + "logs": "Log", + "developers": "Pengembang", + "not_logged_in": "Anda belum masuk", + "search_mode": "Mode Pencarian", + "audio_source": "Sumber Suara", + "ok": "OK", + "failed_to_encrypt": "Gagal mengenkripsi", + "encryption_failed_warning": "Spotube menggunakan enkripsi untuk menyimpan data Anda dengan aman. Namun gagal melakukannya. Jadi itu akan kembali ke penyimpanan yang tidak aman\nJika Anda menggunakan linux, pastikan Anda telah menginstal layanan rahasia (gnome-keyring, kde-wallet, keepassxc, dll)", + "querying_info": "Mencari informasi...", + "piped_api_down": "Piped API tidak aktif", + "piped_down_error_instructions": "Piped Instance {pipedInstance} saat ini tidak aktif\n\nUbah instance atau ubah 'jenis API' menjadi API YouTube resmi\n\nPastikan untuk memulai ulang aplikasi setelah perubahan", + "you_are_offline": "Anda sedang offline", + "connection_restored": "Koneksi internet Anda telah pulih", + "use_system_title_bar": "Gunakan bilah judul sistem", + "crunching_results": "Mengolah hasil...", + "search_to_get_results": "Cari untuk mendapatkan hasil", + "use_amoled_mode": "Tema gelap gulita", + "pitch_dark_theme": "Mode AMOLED", + "normalize_audio": "Normalisasi audio", + "change_cover": "Ganti sampul", + "add_cover": "Tambah sampul", + "restore_defaults": "Kembalikan semula", + "download_music_codec": "Unduh codec musik", + "streaming_music_codec": "Streaming codec musik", + "login_with_lastfm": "Masuk dengan Last.fm", + "connect": "Hubungkan", + "disconnect_lastfm": "Memutuskan Last.fm", + "disconnect": "Memutuskan", + "username": "Username", + "password": "Password", + "login": "Masuk", + "login_with_your_lastfm": "Masuk dengan Last.fm Anda", + "scrobble_to_lastfm": "Scrobble ke Last.fm", + "go_to_album": "Pergi ke Album", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "Lihat Semua", + "genres": "Genre", + "explore_genres": "Jelajahi Genre", + "friends": "Daftar Teman", + "no_lyrics_available": "Maaf, tidak dapat menemukan lirik untuk lagu ini", + "start_a_radio": "Putar Radio", + "how_to_start_radio": "Bagaimana Anda ingin memutar radio?", + "replace_queue_question": "Apakah Anda ingin mengganti antrean saat ini atau menambahkannya?", + "endless_playback": "Pemutaran Tanpa Akhir", + "delete_playlist": "Hapus Daftar Putar", + "delete_playlist_confirmation": "Anda yakin ingin menghapus daftar putar ini?", + "local_tracks": "Trek Lokal", + "song_link": "Tautan Lagu", + "skip_this_nonsense": "Lewati omong kosong ini", + "freedom_of_music": "“Kebebasan Musik”", + "freedom_of_music_palm": "“Kebebasan Musik di telapak tangan Anda”", + "get_started": "Mari kita mulai", + "youtube_source_description": "Direkomendasikan dan berfungsi paling baik.", + "piped_source_description": "Merasa bebas? Sama seperti YouTube tetapi banyak yang gratis.", + "jiosaavn_source_description": "Terbaik untuk wilayah Asia Selatan.", + "highest_quality": "Kualitas Terbaik: {quality}", + "select_audio_source": "Pilih Sumber Suara", + "endless_playback_description": "Tambahkan lagu baru secara otomatis\nke akhir antrean", + "choose_your_region": "Pilih wilayah Anda", + "choose_your_region_description": "Ini akan membantu Spotube menampilkan konten yang tepat\nuntuk lokasi Anda.", + "choose_your_language": "Pilih bahasa Anda", + "help_project_grow": "Bantu proyek ini berkembang", + "help_project_grow_description": "Spotube adalah proyek sumber terbuka. Anda dapat membantu proyek ini berkembang dengan berkontribusi pada proyek, melaporkan bug, atau menyarankan fitur baru.", + "contribute_on_github": "Berkontribusi di GitHub", + "donate_on_open_collective": "Donasi di Open Collective", + "browse_anonymously": "Jelajahi Secara Anonim", + "enable_connect": "Aktifkan Hubungkan", + "enable_connect_description": "Kontrol Spotube dari perangkat lain", + "devices": "Perangkat", + "select": "Pilih", + "connect_client_alert": "Anda dikendalikan oleh {client}", + "this_device": "Perangkat Ini", + "remote": "Remot" +} \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index d96a9372..a0fca998 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -32,6 +32,7 @@ class L10n { const Locale('fr', 'FR'), const Locale('ne', 'NP'), const Locale('hi', 'IN'), + const Locale('id', 'ID'), const Locale('it', 'IT'), const Locale('ja', 'JP'), const Locale('ka', 'GE'), From bf45681deb951c772bf6ca05e213c949c04bded1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?W=CD=8F=20I=CD=8F=20N=CD=8F=20Z=CD=8F=20O=CD=8F=20R=CD=8F?= =?UTF-8?q?=20T=CD=8F?= <75412448+mikropsoft@users.noreply.github.com> Date: Fri, 10 May 2024 20:06:02 +0300 Subject: [PATCH 077/261] feat(translations): Improve tr locales (#1419) * docs: broken link in README.md (fixes #1310) (#1311) * docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. * Improve tr locales --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho --- lib/l10n/app_tr.arb | 196 ++++++++++++++++++++++---------------------- lib/l10n/l10n.dart | 2 +- 2 files changed, 99 insertions(+), 99 deletions(-) diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index a4050853..aab6bc6d 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -3,13 +3,13 @@ "browse": "Göz at", "search": "Ara", "library": "Kütüphane", - "lyrics": "Şarkı Sözleri", + "lyrics": "Şarkı sözleri", "settings": "Ayarlar", - "genre_categories_filter": "Kategorileri veya türleri filtrele...", + "genre_categories_filter": "Kategorileri veya türleri filtreleyin...", "genre": "Tür", "personalized": "Kişiselleştirilmiş", - "featured": "Öne Çıkanlar", - "new_releases": "Yeni Çıkanlar", + "featured": "Öne çıkanlar", + "new_releases": "Yeni çıkanlar", "songs": "Şarkılar", "playing_track": "{track} oynatılıyor", "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parça kaldırılacak\nDevam etmek istiyor musunuz?", @@ -20,15 +20,15 @@ "tracks": "Parçalar", "downloads": "İndirilenler", "filter_playlists": "Oynatma listelerinizi filtreleyin...", - "liked_tracks": "Beğenilen Parçalar", + "liked_tracks": "Beğenilen parçalar", "liked_tracks_description": "Beğendiğiniz tüm parçalar", - "create_playlist": "Oynatma Listesi Oluştur", - "create_a_playlist": "Bir oynatma listesi oluşturun", + "create_playlist": "Oynatma listesi oluştur", + "create_a_playlist": "Bir oynatma listesi oluştur", "update_playlist": "Oynatma listesini güncelle", "create": "Oluştur", "cancel": "İptal", "update": "Güncelle", - "playlist_name": "Oynatma Listesi Adı", + "playlist_name": "Oynatma listesi adı", "name_of_playlist": "Oynatma listesinin adı", "description": "Açıklama", "public": "Halka açık", @@ -39,16 +39,16 @@ "none": "Yok", "sort_a_z": "A - Z'ye göre sırala", "sort_z_a": "Z - A'ya göre sırala", - "sort_artist": "Sanatçıya Göre Sırala", - "sort_album": "Albüme Göre Sırala", - "sort_duration": "Süreye Göre Sırala", - "sort_tracks": "Parçaları Sırala", - "currently_downloading": "Şu An İndirilenler ({tracks_length})", - "cancel_all": "Tümünü İptal Et", - "filter_artist": "Sanatçıları filtrele...", + "sort_artist": "Sanatçıya göre sırala", + "sort_album": "Albüme göre sırala", + "sort_duration": "Süreye göre sırala", + "sort_tracks": "Parçaları sırala", + "currently_downloading": "Şu anda indirilenler ({tracks_length})", + "cancel_all": "Tümünü iptal et", + "filter_artist": "Sanatçıları filtreleyin...", "followers": "{followers} Takipçiler", "add_artist_to_blacklist": "Sanatçıyı kara listeye ekle", - "top_tracks": "En İyi Parçalar", + "top_tracks": "En iyi parçalar", "fans_also_like": "Hayranlar ayrıca şunları da beğendi", "loading": "Yükleniyor...", "artist": "Sanatçı", @@ -57,7 +57,7 @@ "follow": "Takip et", "artist_url_copied": "Sanatçı bağlantısı panoya kopyalandı", "added_to_queue": "Kuyruğa {tracks} parçası eklendi", - "filter_albums": "Albümleri filtrele...", + "filter_albums": "Albümleri filtreleyin...", "synced": "Senkronize edildi", "plain": "Sade", "shuffle": "Karıştır", @@ -68,19 +68,19 @@ "time": "Zaman", "more_actions": "Daha fazla eylem", "download_count": "İndir ({count})", - "add_count_to_playlist": "Oynatma Listesine ({count}) ekle", - "add_count_to_queue": "Kuyruğa ({count}) ekle", - "play_count_next": "({count}) sonrakini oynat", + "add_count_to_playlist": "Oynatma Listesine ekle ({count})", + "add_count_to_queue": "Kuyruğa ekle ({count})", + "play_count_next": "Sonrakini oynat ({count})", "album": "Albüm", "copied_to_clipboard": "{data} panoya kopyalandı", - "add_to_following_playlists": "{track} parçasını aşağıdaki Oynatma Listelerine ekle", + "add_to_following_playlists": "{track} parçasını aşağıdaki oynatma listelerine ekle", "add": "Ekle", "added_track_to_queue": "{track} kuyruğa eklendi", "add_to_queue": "Kuyruğa ekle", "track_will_play_next": "{track} bir sonraki çalacak", "play_next": "Sonrakini oynat", - "removed_track_from_queue": "{track} sıradan kaldırıldı", - "remove_from_queue": "Sıradan kaldır", + "removed_track_from_queue": "{track} kuyruktan kaldırıldı", + "remove_from_queue": "Kuyruktan kaldır", "remove_from_favorites": "Favorilerden kaldır", "save_as_favorite": "Favori olarak kaydet", "add_to_playlist": "Oynatma listesine ekle", @@ -88,7 +88,7 @@ "add_to_blacklist": "Kara listeye ekle", "remove_from_blacklist": "Kara listeden kaldır", "share": "Paylaş", - "mini_player": "Mini Oynatıcı", + "mini_player": "Mini oynatıcı", "slide_to_seek": "İleri veya geri arama yapmak için kaydırın", "shuffle_playlist": "Oynatma listesini karıştır", "unshuffle_playlist": "Oynatma listesinin karışıklığını kaldır", @@ -98,27 +98,27 @@ "resume_playback": "Oynatmayı sürdür", "loop_track": "Döngü parçası", "repeat_playlist": "Oynatma listesini tekrarla", - "queue": "Sıra", - "alternative_track_sources": "Alternatif yol kaynakları", + "queue": "Kuyruk", + "alternative_track_sources": "Alternatif parça kaynakları", "download_track": "Parçayı indir", - "tracks_in_queue": "{tracks} parça sırada", + "tracks_in_queue": "{tracks} parça kuyrukta", "clear_all": "Tümünü temizle", "show_hide_ui_on_hover": "Fareyle üzerine gelindiğinde kullanıcı arayüzünü göster/gizle", "always_on_top": "Her zaman üstte", "exit_mini_player": "Mini oynatıcıdan çık", "download_location": "İndirme konumu", "account": "Hesap", - "login_with_spotify": "Spotify hesabınızla giriş yapın", + "login_with_spotify": "Spotify hesabı ile giriş yap", "connect_with_spotify": "Spotify ile bağlan", - "logout": "Çıkış Yap", - "logout_of_this_account": "Bu hesaptan çıkış yap", - "language_region": "Dil ve Bölge", - "language": "Dil", - "system_default": "Sistem Varsayılanı", - "market_place_region": "Pazaryeri Bölgesi", - "recommendation_country": "Tavsiye Edilen Ülke", + "logout": "Çıkış yap", + "logout_of_this_account": "Hesaptan çıkış yap", + "language_region": "Dil ve bölge", + "language": "Tercih edilen dil", + "system_default": "Sistem varsayılanı", + "market_place_region": "Tercih edilen bölge", + "recommendation_country": "Tavsiye edilen ülke", "appearance": "Görünüm", - "layout_mode": "Düzen Modu", + "layout_mode": "Düzen modu", "override_layout_settings": "Duyarlı düzen modu ayarlarını geçersiz kıl", "adaptive": "Uyarlanabilir", "compact": "Sıkıştırılmış", @@ -127,35 +127,35 @@ "dark": "Koyu", "light": "Açık", "system": "Sistem", - "accent_color": "Vurgu Rengi", + "accent_color": "Vurgu rengi", "sync_album_color": "Albüm rengini senkronize et", "sync_album_color_description": "Vurgu rengi olarak albüm resminin baskın rengini kullanır", "playback": "Oynatma", - "audio_quality": "Ses Kalitesi", + "audio_quality": "Ses kalitesi", "high": "Yüksek", "low": "Düşük", - "pre_download_play": "Ön yükleme ve oynatma", - "pre_download_play_description": "Ses akışı yerine baytları indirin ve oynatın (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)", - "skip_non_music": "Müzik olmayan bölümleri atla (SponsorBlock)", + "pre_download_play": "Önceden indir ve oynat", + "pre_download_play_description": "Ses akışı yerine baytları indir ve oynat (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)", + "skip_non_music": "Müzik olmayan bölümleri atlat (SponsorBlock)", "blacklist_description": "Kara listeye alınan parçalar ve sanatçılar", "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin tamamlanmasını bekleyin", "desktop": "Masaüstü", - "close_behavior": "Kapatma Davranışı", + "close_behavior": "Kapatma davranışı", "close": "Kapat", "minimize_to_tray": "Tepsiye küçült", "show_tray_icon": "Sistem tepsisi simgesini göster", "about": "Hakkında", "u_love_spotube": "Spotube'u sevdiğinizi biliyoruz", "check_for_updates": "Güncellemeleri kontrol et", - "about_spotube": "Spotube Hakkında", + "about_spotube": "Spotube hakkında", "blacklist": "Kara liste", "please_sponsor": "Sponsor Ol/Bağış Yap", - "spotube_description": "Spotube, hafif, platformlar arası, herkes için ücretsiz bir spotify istemcisidir", + "spotube_description": "Spotube, hafif, platformlar arası uyumlu ve herkes için ücretsiz bir Spotify istemcisidir.", "version": "Sürüm", - "build_number": "Derleme Numarası", - "founder": "Kurucu", + "build_number": "Derleme numarası", + "founder": "Geliştirici", "repository": "Depo", - "bug_issues": "Hata+Sorunlar", + "bug_issues": "Hata + Sorunlar", "made_with": "❤️ ile Bangladeş'te yapıldı", "kingkor_roy_tirtho": "Kingkor Roy Tirtho", "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", @@ -163,31 +163,31 @@ "add_spotify_credentials": "Başlamak için spotify kimlik bilgilerinizi ekleyin", "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, kimlik bilgilerinizden hiçbiri toplanmayacak veya kimseyle paylaşılmayacak", "know_how_to_login": "Bunu nasıl yapacağınızı bilmiyor musunuz?", - "follow_step_by_step_guide": "Adım Adım kılavuzu takip edin", - "spotify_cookie": "Spotify {name} Çerezi", - "cookie_name_cookie": "{name} Çerezi", + "follow_step_by_step_guide": "Adım adım kılavuzu takip edin", + "spotify_cookie": "Spotify {name} çerezi", + "cookie_name_cookie": "{name} çerezi", "fill_in_all_fields": "Lütfen tüm alanları doldurun", - "submit": "Gönder", + "submit": "Başvur", "exit": "Çık", "previous": "Önceki", "next": "Sonraki", "done": "Bitti", "step_1": "1. Adım", "first_go_to": "İlk olarak şuraya gidin:", - "login_if_not_logged_in": "ve oturum açmadıysanız Oturum Açın/Kaydolun", + "login_if_not_logged_in": "ve oturum açmadıysanız Oturum açın/Kaydolun", "step_2": "2. Adım", - "step_2_steps": "1. Giriş yaptıktan sonra, Tarayıcı geliştirme araçlarını açmak için F12 veya Fareye Sağ Tıklayın > İncele'ye basın.\n2. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin.\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin", + "step_2_steps": "1. Oturum açtıktan sonra, tarayıcı geliştirme araçlarını açmak için F12'ye veya fareye sağ tıklayın > İncele'ye basın.\n2. Daha sonra \"Uygulama\" sekmesine (Chrome, Edge, Brave vb..) veya \"Depolama\" sekmesine (Firefox, Palemoon vb..) gidin\n3. \"Çerezler\" bölümüne, ardından \"https://accounts.spotify.com\" alt bölümüne gidin", "step_3": "3. Adım", "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyalayın", "success_emoji": "Başarılı🥳", - "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Aferin, dostum!", + "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Tebrik ederim!", "step_4": "4. Adım", "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştırın", "something_went_wrong": "Bir hata oluştu", - "piped_instance": "Piped Sunucu Örneği", + "piped_instance": "Piped sunucu örneği", "piped_description": "Parça eşleştirme için kullanılacak Piped sunucu örneği", "piped_warning": "Bazıları iyi çalışmayabilir. Yani riski size ait olmak üzere kullanın", - "generate_playlist": "Oynatma Listesi Oluştur", + "generate_playlist": "Oynatma listesi oluştur", "track_exists": "{track} parçası zaten var", "replace_downloaded_tracks": "İndirilen tüm parçaları değiştir", "skip_download_tracks": "İndirilen tüm parçaları indirmeyi atla", @@ -195,8 +195,8 @@ "replace": "Değiştir", "skip": "Atla", "select_up_to_count_type": "En fazla {count} {type} seçin", - "select_genres": "Türleri Seç", - "add_genres": "Tür Ekle", + "select_genres": "Türleri seç", + "add_genres": "Tür ekle", "country": "Ülke", "number_of_tracks_generate": "Oluşturulacak parça sayısı", "acousticness": "Akustiklik", @@ -212,7 +212,7 @@ "duration": "Süre (sn)", "tempo": "Tempo (BPM)", "mode": "Mod", - "time_signature": "Zaman İmzası", + "time_signature": "Zaman imzası", "short": "Kısa", "medium": "Orta", "long": "Uzun", @@ -220,29 +220,29 @@ "max": "Maks", "target": "Hedef", "moderate": "Orta", - "deselect_all": "Tüm Seçimleri Kaldır", - "select_all": "Tümünü Seç", + "deselect_all": "Tüm seçimleri kaldır", + "select_all": "Tümünü seç", "are_you_sure": "Emin misiniz?", "generating_playlist": "Özel oynatma listeniz oluşturuluyor...", "selected_count_tracks": "{count} parça seçildi", - "download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapıyor ve Müziğin yaratıcı toplumuna zarar veriyorsunuz demektir. Umarım bunun farkındasınızdır. Her zaman, Sanatçının sıkı çalışmasına saygı duymaya ve desteklemeye çalışın", - "download_ip_ban_warning": "Bu arada, IP'niz normalden daha fazla indirme isteği nedeniyle YouTube'da engellenebilir. IP engelleme, YouTube'u (oturum açmış olsanız bile) o IP cihazından en az 2 -3 ay kullanamayacağınız anlamına gelir. Ve eğer böyle bir şey olursa Spotube'un hiçbir sorumluluğu yok", + "download_warning": "Tüm şarkıları toplu olarak indiriyorsanız, açıkça müzik korsanlığı yapıyorsunuz ve müzik dünyasının yaratıcı topluluğuna zarar veriyorsunuz demektir. Umuyorum bunun farkındasınızdır. Her zaman, sanatçıların emeğine saygı göstermeyi ve desteklemeyi deneyin.", + "download_ip_ban_warning": "Ayrıca, normalden fazla indirme istekleri nedeniyle YouTube'da IP'niz engellenebilir. IP engeli, en az 2-3 ay boyunca YouTube'u (hatta oturum açmış olsanız bile) o IP cihazından kullanamayacağınız anlamına gelir. Ve eğer böyle bir durum yaşanırsa, Spotube bundan hiçbir sorumluluk kabul etmez.", "by_clicking_accept_terms": "\"Kabul et\" e tıklayarak aşağıdaki şartları kabul etmiş olursunuz:", - "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben kötüyüm", + "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben fakir biriyim.", "download_agreement_2": "Sanatçıyı elimden geldiğince destekleyeceğim ve bunu sadece sanatını satın alacak param olmadığı için yapıyorum", - "download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut işlemimden kaynaklanan herhangi bir kazadan Spotube'u veya sahiplerini/katkıda bulunanlarını sorumlu tutmuyorum", + "download_agreement_3": "YouTube'da IP'min engellenebileceğinin tamamen farkındayım ve mevcut eylemlerimden kaynaklanan herhangi bir kaza için Spotube'u veya sahiplerini/katkıda bulunanları sorumlu tutmuyorum.", "decline": "Reddet", "accept": "Kabul et", "details": "Detaylar", "youtube": "YouTube", "channel": "Kanal", - "likes": "Beğeniler", + "likes": "Beğenenler", "dislikes": "Beğenmeyenler", "views": "İzlenmeler", "streamUrl": "Akış bağlantısı", "stop": "Durdur", - "sort_newest": "En yeniye göre sırala", - "sort_oldest": "Eklenen en eskiye göre sırala", + "sort_newest": "En yeni eklenene göre sırala.", + "sort_oldest": "En eski eklenene göre sırala", "sleep_timer": "Uyku Zamanlayıcısı", "mins": "{minutes} Dakika", "hours": "{hours} Saatler", @@ -251,11 +251,11 @@ "logs": "Günlükler", "developers": "Geliştiriciler", "not_logged_in": "Giriş yapmadınız", - "search_mode": "Arama Modu", - "audio_source": "Ses Kaynağı", + "search_mode": "Arama modu", + "audio_source": "Ses kaynağı", "ok": "Tamam", "failed_to_encrypt": "Şifreleme başarısız oldu", - "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde saklamak için şifreleme kullanır. Ama başaramadı. Bu yüzden güvensiz depolamaya geri dönecek\nLinux kullanıyorsanız, lütfen herhangi bir gizli servisin (gnome - anahtarlık, kde - cüzdan, keepassxc vb.) yüklü olduğundan emin olun", + "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle, güvensiz depolamaya geri dönecektir\nLinux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. herhangi bir gizli servisin yüklü olduğundan emin olun.", "querying_info": "Bilgi sorgulanıyor...", "piped_api_down": "Piped API kapalı", "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nÖrneği değiştirin veya 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun", @@ -263,8 +263,8 @@ "connection_restored": "İnternet bağlantınız geri yüklendi", "use_system_title_bar": "Sistem başlık çubuğunu kullan", "crunching_results": "Sonuçlar...", - "search_to_get_results": "Sonuç almak için ara", - "use_amoled_mode": "AMOLED Modunu Kullan", + "search_to_get_results": "Sonuç almak için arayın", + "use_amoled_mode": "AMOLED modu kullan", "pitch_dark_theme": "Zifiri karanlık koyu tema", "normalize_audio": "Sesi normalleştir", "change_cover": "Kapağı değiştir", @@ -277,48 +277,48 @@ "disconnect_lastfm": "Last.fm bağlantısını kes", "disconnect": "Bağlantıyı kes", "username": "Kullanıcı adı", - "password": "Parola", - "login": "Giriş", + "password": "Şifre", + "login": "Giriş yap", "login_with_your_lastfm": "Last.fm hesabınızla giriş yapın", "scrobble_to_lastfm": "Last.fm için Scrobble", - "go_to_album": "Albüme Git", - "discord_rich_presence": "Discord Zengin Varlığı", - "browse_all": "Tümüne Göz At", - "genres": "Müzik Türleri", - "explore_genres": "Türleri Keşfet", + "go_to_album": "Albüme git", + "discord_rich_presence": "Discord zengin varlığı", + "browse_all": "Tümüne göz at", + "genres": "Müzik türleri", + "explore_genres": "Türleri keşfet", "friends": "Arkadaşlar", "no_lyrics_available": "Üzgünüz, bu parçanın sözleri bulunamıyor", - "start_a_radio": "Radyo Başlat", + "start_a_radio": "Radyo başlat", "how_to_start_radio": "Radyoyu nasıl başlatmak istersiniz?", "replace_queue_question": "Mevcut kuyruğu değiştirmek mi yoksa eklemek mi istersiniz?", - "endless_playback": "Sonsuz Olarak Oynat", - "delete_playlist": "Oynatma Listesini Sil", + "endless_playback": "Sonsuz olarak oynat", + "delete_playlist": "Oynatma listesini sil", "delete_playlist_confirmation": "Bu oynatma listesini silmek istediğinizden emin misiniz?", - "local_tracks": "Yerel Parçalar", - "song_link": "Şarkı Bağlantısı", + "local_tracks": "Yerel parçalar", + "song_link": "Şarkı bağlantısı", "skip_this_nonsense": "Bu saçmalığı atla", - "freedom_of_music": "“Müzik Özgürlüğü”", - "freedom_of_music_palm": "“Müzik Özgürlüğü avucunuzun içinde”", + "freedom_of_music": "“Müzik özgürlüğü”", + "freedom_of_music_palm": "“Müzik özgürlüğü avucunuzun içinde”", "get_started": "Haydi başlayalım", "youtube_source_description": "Tavsiye edilir ve en iyi şekilde çalışır.", - "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı ama çok daha fazla ücretsiz.", + "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı, ama çok daha özgür.", "jiosaavn_source_description": "Güney Asya bölgesi için en iyisi.", - "highest_quality": "En Yüksek Kalite: {quality}", - "select_audio_source": "Ses Kaynağını Seç", - "endless_playback_description": "Yeni şarkıları otomatik olarak \nkuyruğun sonuna ekle", + "highest_quality": "En yüksek kalite: {quality}", + "select_audio_source": "Ses kaynağını seçin", + "endless_playback_description": "Yeni şarkıları otomatik olarak\nkuyruğun sonuna ekle", "choose_your_region": "Bölgenizi seçin", - "choose_your_region_description": "Bu, Spotube'un size doğru içeriği göstermesine yardımcı olacaktır\nkonumunuz için.", + "choose_your_region_description": "Bu, Spotube'un konumunuza uygun içerikleri göstermesine yardımcı olacaktır.", "choose_your_language": "Dilinizi seçin", - "help_project_grow": "Bu projenin büyümesine yardımcı ol", + "help_project_grow": "Bu projenin büyümesine yardımcı olun", "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.", - "contribute_on_github": "GitHub'a katkıda bulunun", - "donate_on_open_collective": "Open Collective'e bağış yap", - "browse_anonymously": "Anonim Olarak Göz at", - "enable_connect": "Bağlantıyı Etkinleştir", + "contribute_on_github": "GitHub'da katkıda bulun", + "donate_on_open_collective": "Open Collective'de bağış yap", + "browse_anonymously": "Anonim olarak giriş yap", + "enable_connect": "Bağlanmayı etkinleştir", "enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin", "devices": "Cihazlar", "select": "Seç", "connect_client_alert": "{client} tarafından kontrol ediliyorsun.", - "this_device": "Bu Cihaz", + "this_device": "Bu cihaz", "remote": "Yönet" } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index a0fca998..ebdc4b61 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -7,7 +7,7 @@ /// TexturedPolak@github => Polish /// yuri-val@github => Ukrainian /// energywave@github, ncvescera@github, OpenCode@github => Italian -/// mdksec@github, mikropsoft@github => Turkish +/// mikropsoft@github => Turkish /// Stephan-P@github, SecularSteve@github => Dutch /// doannc2212@github => Vietnamese /// sappho192@github => Korean From 8fad2251b3536e9468e0fb193939ead98bad3bc6 Mon Sep 17 00:00:00 2001 From: Akash Pattnaik Date: Fri, 10 May 2024 22:46:10 +0530 Subject: [PATCH 078/261] feat(player): add volume slider floating label showing percentage (#1445) * docs: broken link in README.md (fixes #1310) (#1311) * docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. * add volume level tooltip in volume_slider --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho --- lib/collections/env.dart | 2 +- lib/components/player/volume_slider.dart | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/collections/env.dart b/lib/collections/env.dart index 89a777b6..df45cee9 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -41,4 +41,4 @@ abstract class Env { kIsFlatpak || _enableUpdateChecker == "1"; static String discordAppId = "1176718791388975124"; -} +} \ No newline at end of file diff --git a/lib/components/player/volume_slider.dart b/lib/components/player/volume_slider.dart index 102bbef6..8483143b 100644 --- a/lib/components/player/volume_slider.dart +++ b/lib/components/player/volume_slider.dart @@ -1,6 +1,5 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -31,11 +30,17 @@ class VolumeSlider extends HookConsumerWidget { } } }, - child: Slider( - min: 0, - max: 1, - value: value, - onChanged: onChanged, + child: SliderTheme( + data: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + ), + child: Slider( + min: 0, + max: 1, + label: (value * 100).toStringAsFixed(0), + value: value, + onChanged: onChanged, + ), ), ); return Row( From 9aea35468fa7cd176ddc8810b37b90c2d8246931 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 23 May 2024 15:13:02 +0600 Subject: [PATCH 079/261] fix: fallback to LRCLIB when lyrics line less than 6 lines #1461 --- lib/provider/spotify/lyrics/synced.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index 6ce74ae7..066596a9 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -127,7 +127,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier final token = await spotify.getCredentials(); SubtitleSimple lyrics = await getSpotifyLyrics(token.accessToken); - if (lyrics.lyrics.isEmpty) { + if (lyrics.lyrics.isEmpty || lyrics.lyrics.length <= 5) { lyrics = await getLRCLibLyrics(); } From 22caa818f4ac31626aaff6952e43512b42237d00 Mon Sep 17 00:00:00 2001 From: Blake Leonard Date: Thu, 23 May 2024 05:18:01 -0400 Subject: [PATCH 080/261] feat: Local music library (#1479) * feat: add one additional library folder This folder just doesn't get downloaded to. I think I'm going to rework it so that it can be multiple folders, but I'm going to commit my progress so far anyway. Signed-off-by: Blake Leonard * chore: update dependencies so that it builds I'm not sure if this breaks CI or something, but I couldn't build it locally to test my changes, so I made these changes and it builds again. Signed-off-by: Blake Leonard * feat: index multiple folders of local music If you used a previous commit from this branch, this is a breaking change, because it changes the type of a configuration field. but since this is still in development, it should be fine. Signed-off-by: Blake Leonard * refactor: manage local library in local tracks tab This also refactors the list to use slivers instead. That's the easiest way to have multiple scrolling lists here... The console keeps getting spammed with some intermediate layout error but I can't hold it long enough to figure out what's causing it. Signed-off-by: Blake Leonard * refactor: use folder add/remove icons in library Signed-off-by: Blake Leonard * refactor: remove redundant settings page Signed-off-by: Blake Leonard * refactor: rename "Local Tracks" to just "Local" Not sure if this would be the recommended way to do it... Signed-off-by: Blake Leonard * fix: console spam about useless Expanded Signed-off-by: Blake Leonard * chore: remove completed TODO Signed-off-by: Blake Leonard * chore: use new Platform constants; regenerate plugins Signed-off-by: Blake Leonard * refactor: put local libraries on separate pages Signed-off-by: Blake Leonard --------- Signed-off-by: Blake Leonard --- lib/collections/routes.dart | 12 + lib/collections/spotube_icons.dart | 2 + lib/components/library/user_local_tracks.dart | 352 +++++++----------- lib/l10n/app_en.arb | 6 +- lib/pages/library/library.dart | 2 +- lib/pages/library/local_folder.dart | 236 ++++++++++++ lib/pages/settings/sections/downloads.dart | 1 + .../user_preferences_provider.dart | 5 + .../user_preferences_state.dart | 1 + .../user_preferences_state.freezed.dart | 35 +- .../user_preferences_state.g.dart | 5 + linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 20 +- pubspec.yaml | 11 + untranslated_messages.json | 156 +++++++- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 19 files changed, 619 insertions(+), 236 deletions(-) create mode 100644 lib/pages/library/local_folder.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 080cbd8a..340b816a 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -14,6 +14,7 @@ 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/local_folder.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart'; @@ -113,6 +114,17 @@ final routerProvider = Provider((ref) { ), ), ]), + GoRoute( + path: "local", + pageBuilder: (context, state) { + assert(state.extra is String); + return SpotubePage( + child: LocalLibraryPage(state.extra as String, + isDownloads: state.uri.queryParameters["downloads"] != null + ), + ); + }, + ), ]), GoRoute( path: "/lyrics", diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 6de21284..2da09f52 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -121,4 +121,6 @@ abstract class SpotubeIcons { static const monitor = FeatherIcons.monitor; static const power = FeatherIcons.power; static const bluetooth = FeatherIcons.bluetooth; + static const folderAdd = FeatherIcons.folderPlus; + static const folderRemove = FeatherIcons.folderMinus; } diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index a7b2102b..d5115aaa 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -1,11 +1,14 @@ import 'dart:io'; import 'package:catcher_2/catcher_2.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:collection/collection.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:mime/mime.dart'; @@ -27,6 +30,7 @@ import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; // ignore: depend_on_referenced_packages import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; @@ -59,116 +63,125 @@ enum SortBy { album, } -final localTracksProvider = FutureProvider>((ref) async { +final localTracksProvider = FutureProvider>>((ref) async { try { - if (kIsWeb) return []; + if (kIsWeb) return {}; + final Map> tracks = {}; + final downloadLocation = ref.watch( userPreferencesProvider.select((s) => s.downloadLocation), ); - if (downloadLocation.isEmpty) return []; final downloadDir = Directory(downloadLocation); if (!await downloadDir.exists()) { await downloadDir.create(recursive: true); - return []; } - final entities = downloadDir.listSync(recursive: true); + final localLibraryLocations = ref.watch( + userPreferencesProvider.select((s) => s.localLibraryLocation), + ); - final filesWithMetadata = (await Future.wait( - entities.map((e) => File(e.path)).where((file) { - final mimetype = lookupMimeType(file.path); - return mimetype != null && supportedAudioTypes.contains(mimetype); - }).map( - (file) async { - try { - final metadata = await MetadataGod.readMetadata(file: file.path); + for (var location in [downloadLocation, ...localLibraryLocations]) { + if (location.isEmpty) continue; + final entities = []; + final dir = Directory(location); + if (await Directory(location).exists()) { + entities.addAll(Directory(location).listSync(recursive: true)); + } - final imageFile = File(join( - (await getTemporaryDirectory()).path, - "spotube", - basenameWithoutExtension(file.path) + - imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, - )); - if (!await imageFile.exists() && metadata.picture != null) { - await imageFile.create(recursive: true); - await imageFile.writeAsBytes( - metadata.picture?.data ?? [], - mode: FileMode.writeOnly, - ); + final filesWithMetadata = (await Future.wait( + entities.map((e) => File(e.path)).where((file) { + final mimetype = lookupMimeType(file.path); + return mimetype != null && supportedAudioTypes.contains(mimetype); + }).map( + (file) async { + try { + final metadata = await MetadataGod.readMetadata(file: file.path); + + final imageFile = File(join( + (await getTemporaryDirectory()).path, + "spotube", + basenameWithoutExtension(file.path) + + imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, + )); + if (!await imageFile.exists() && metadata.picture != null) { + await imageFile.create(recursive: true); + await imageFile.writeAsBytes( + metadata.picture?.data ?? [], + mode: FileMode.writeOnly, + ); + } + + return {"metadata": metadata, "file": file, "art": imageFile.path}; + } catch (e, stack) { + if (e is FfiException) { + return {"file": file}; + } + Catcher2.reportCheckedError(e, stack); + return {}; } + }, + ), + )) + .where((e) => e.isNotEmpty) + .toList(); - return {"metadata": metadata, "file": file, "art": imageFile.path}; - } catch (e, stack) { - if (e is FfiException) { - return {"file": file}; - } - Catcher2.reportCheckedError(e, stack); - return {}; - } - }, - ), - )) - .where((e) => e.isNotEmpty) - .toList(); - - final tracks = filesWithMetadata - .map( - (fileWithMetadata) => LocalTrack.fromTrack( - track: Track().fromFile( - fileWithMetadata["file"], - metadata: fileWithMetadata["metadata"], - art: fileWithMetadata["art"], + // ignore: no_leading_underscores_for_local_identifiers + final _tracks = filesWithMetadata + .map( + (fileWithMetadata) => LocalTrack.fromTrack( + track: Track().fromFile( + fileWithMetadata["file"], + metadata: fileWithMetadata["metadata"], + art: fileWithMetadata["art"], + ), + path: fileWithMetadata["file"].path, ), - path: fileWithMetadata["file"].path, - ), - ) - .toList(); + ) + .toList(); + tracks[location] = _tracks; + } return tracks; } catch (e, stack) { Catcher2.reportCheckedError(e, stack); - return []; + return {}; } }); class UserLocalTracks extends HookConsumerWidget { const UserLocalTracks({super.key}); - Future playLocalTracks( - WidgetRef ref, - List tracks, { - LocalTrack? currentTrack, - }) async { - final playlist = ref.read(proxyPlaylistProvider); - final playback = ref.read(proxyPlaylistProvider.notifier); - currentTrack ??= tracks.first; - final isPlaylistPlaying = playlist.containsTracks(tracks); - if (!isPlaylistPlaying) { - await playback.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } - @override Widget build(BuildContext context, ref) { - final sortBy = useState(SortBy.none); - final playlist = ref.watch(proxyPlaylistProvider); - final trackSnapshot = ref.watch(localTracksProvider); - final isPlaylistPlaying = - playlist.containsTracks(trackSnapshot.asData?.value ?? []); - final searchController = useTextEditingController(); - useValueListenable(searchController); - final searchFocus = useFocusNode(); - final isFiltering = useState(false); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final preferences = ref.watch(userPreferencesProvider); - final controller = useScrollController(); + final addLocalLibraryLocation = useCallback(() async { + if (kIsMobile || kIsMacOS) { + final dirStr = await FilePicker.platform.getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + if (preferences.localLibraryLocation.contains(dirStr)) return; + preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]); + } else { + String? dirStr = await getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + if (preferences.localLibraryLocation.contains(dirStr)) return; + preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]); + } + }, [preferences.localLibraryLocation]); + + final removeLocalLibraryLocation = useCallback((String location) { + if (!preferences.localLibraryLocation.contains(location)) return; + preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation]..remove(location)); + }, [preferences.localLibraryLocation]); + + // This is just to pre-load the tracks. + // For now, this gets all of them. + ref.watch(localTracksProvider); return Column( children: [ @@ -177,155 +190,42 @@ class UserLocalTracks extends HookConsumerWidget { child: Row( children: [ const SizedBox(width: 5), - FilledButton( - onPressed: trackSnapshot.asData?.value != null - ? () async { - if (trackSnapshot.asData?.value.isNotEmpty == true) { - if (!isPlaylistPlaying) { - await playLocalTracks( - ref, - trackSnapshot.asData!.value, - ); - } - } - } - : null, - child: Row( - children: [ - Text(context.l10n.play), - Icon( - isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, - ) - ], - ), - ), - const Spacer(), - ExpandableSearchButton( - isFiltering: isFiltering.value, - onPressed: (value) => isFiltering.value = value, - searchFocus: searchFocus, - ), - const SizedBox(width: 10), - SortTracksDropdown( - value: sortBy.value, - onChanged: (value) { - sortBy.value = value; - }, - ), - const SizedBox(width: 5), - FilledButton( - child: const Icon(SpotubeIcons.refresh), - onPressed: () { - ref.invalidate(localTracksProvider); - }, + TextButton.icon( + icon: const Icon(SpotubeIcons.folderAdd), + label: Text(context.l10n.add_library_location), + onPressed: addLocalLibraryLocation, ) - ], - ), + ] + ) ), - ExpandableSearchField( - searchController: searchController, - searchFocus: searchFocus, - isFiltering: isFiltering.value, - onChangeFiltering: (value) => isFiltering.value = value, - ), - trackSnapshot.when( - data: (tracks) { - final sortedTracks = useMemoized(() { - return ServiceUtils.sortTracks(tracks, sortBy.value); - }, [sortBy.value, tracks]); - - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return sortedTracks; + Expanded( + child: ListView.builder( + itemCount: preferences.localLibraryLocation.length+1, + itemBuilder: (context, index) { + late final String location; + if (index == 0) { + location = preferences.downloadLocation; + } else { + location = preferences.localLibraryLocation[index-1]; } - return sortedTracks - .map((e) => ( - weightedRatio( - "${e.name} - ${e.artists?.asString() ?? ""}", - searchController.text, - ), - e, - )) - .toList() - .sorted( - (a, b) => b.$1.compareTo(a.$1), - ) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList() - .toList(); - }, [searchController.text, sortedTracks]); - - if (!trackSnapshot.isLoading && filteredTracks.isEmpty) { - return const Expanded( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [NotFound()], - ), + return ListTile( + title: preferences.downloadLocation != location ? Text(location) + : Text(context.l10n.downloads), + trailing: preferences.downloadLocation != location ? Tooltip( + message: context.l10n.remove_library_location, + child: IconButton( + icon: Icon(SpotubeIcons.folderRemove, color: Colors.red[400]), + onPressed: () => removeLocalLibraryLocation(location), + ), + ) : null, + onTap: () async { + context.go("/library/local${location == preferences.downloadLocation ? "?downloads=1" : ""}", extra: location); + } ); } - - return Expanded( - child: RefreshIndicator( - onRefresh: () async { - ref.invalidate(localTracksProvider); - }, - child: InterScrollbar( - controller: controller, - 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( - playlist: playlist, - track: FakeData.track, - index: index, - ); - } - - final track = filteredTracks[index]; - return TrackTile( - index: index, - playlist: playlist, - track: track, - userPlaylist: false, - onTap: () async { - await playLocalTracks( - ref, - sortedTracks, - currentTrack: track, - ); - }, - ); - }, - ), - ), - ), - ), - ); - }, - loading: () => Expanded( - child: Skeletonizer( - enabled: true, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) => TrackTile( - track: FakeData.track, - index: index, - playlist: playlist, - ), - ), - ), ), - error: (error, stackTrace) => - Text(error.toString() + stackTrace.toString()), - ) - ], + ), + ] ); } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 832862c0..a90fd35e 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -107,6 +107,9 @@ "always_on_top": "Always on top", "exit_mini_player": "Exit Mini player", "download_location": "Download location", + "local_library": "Local library", + "add_library_location": "Add to library", + "remove_library_location": "Remove from library", "account": "Account", "login_with_spotify": "Login with your Spotify account", "connect_with_spotify": "Connect with Spotify", @@ -295,6 +298,7 @@ "delete_playlist": "Delete Playlist", "delete_playlist_confirmation": "Are you sure you want to delete this playlist?", "local_tracks": "Local Tracks", + "local_tab": "Local", "song_link": "Song Link", "skip_this_nonsense": "Skip this nonsense", "freedom_of_music": "“Freedom of Music”", @@ -321,4 +325,4 @@ "connect_client_alert": "You're being controlled by {client}", "this_device": "This Device", "remote": "Remote" -} \ No newline at end of file +} diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index ccdb6a35..eff30348 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -27,7 +27,7 @@ class LibraryPage extends HookConsumerWidget { leading: ThemedButtonsTabBar( tabs: [ Tab(text: " ${context.l10n.playlists} "), - Tab(text: " ${context.l10n.local_tracks} "), + Tab(text: " ${context.l10n.local_tab} "), Tab( child: Badge( isLabelVisible: downloadingCount > 0, diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart new file mode 100644 index 00000000..89d70e09 --- /dev/null +++ b/lib/pages/library/local_folder.dart @@ -0,0 +1,236 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/library/user_local_tracks.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/page_window_title_bar.dart'; +import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class LocalLibraryPage extends HookConsumerWidget { + final String location; + final bool isDownloads; + const LocalLibraryPage(this.location, {super.key, this.isDownloads = false}); + + Future playLocalTracks( + WidgetRef ref, + List tracks, { + LocalTrack? currentTrack, + }) async { + final playlist = ref.read(proxyPlaylistProvider); + final playback = ref.read(proxyPlaylistProvider.notifier); + currentTrack ??= tracks.first; + final isPlaylistPlaying = playlist.containsTracks(tracks); + if (!isPlaylistPlaying) { + await playback.load( + tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playback.jumpToTrack(currentTrack); + } + } + + @override + Widget build(BuildContext context, ref) { + final sortBy = useState(SortBy.none); + final playlist = ref.watch(proxyPlaylistProvider); + final trackSnapshot = ref.watch(localTracksProvider); + final isPlaylistPlaying = + playlist.containsTracks(trackSnapshot.asData?.value.values.flattened.toList() ?? []); + + final searchController = useTextEditingController(); + useValueListenable(searchController); + final searchFocus = useFocusNode(); + final isFiltering = useState(false); + + final controller = useScrollController(); + + return SafeArea( + bottom: false, + child: Scaffold( + appBar: PageWindowTitleBar( + leading: const BackButton(), + centerTitle: true, + title: Text(isDownloads ? context.l10n.downloads : location), + backgroundColor: Colors.transparent, + ), + extendBodyBehindAppBar: true, + body: Column( + children: [ + const SizedBox(height: 56), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const SizedBox(width: 5), + FilledButton( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot.asData?.value.isNotEmpty == true) { + if (!isPlaylistPlaying) { + await playLocalTracks( + ref, + trackSnapshot.asData!.value[location] ?? [], + ); + } + } + } + : null, + child: Row( + children: [ + Text(context.l10n.play), + Icon( + isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, + ) + ], + ), + ), + const Spacer(), + ExpandableSearchButton( + isFiltering: isFiltering.value, + onPressed: (value) => isFiltering.value = value, + searchFocus: searchFocus, + ), + const SizedBox(width: 10), + SortTracksDropdown( + value: sortBy.value, + onChanged: (value) { + sortBy.value = value; + }, + ), + const SizedBox(width: 5), + FilledButton( + child: const Icon(SpotubeIcons.refresh), + onPressed: () { + ref.invalidate(localTracksProvider); + }, + ) + ], + ), + ), + ExpandableSearchField( + searchController: searchController, + searchFocus: searchFocus, + isFiltering: isFiltering.value, + onChangeFiltering: (value) => isFiltering.value = value, + ), + trackSnapshot.when( + data: (tracks) { + final sortedTracks = useMemoized(() { + return ServiceUtils.sortTracks(tracks[location] ?? [], sortBy.value); + }, [sortBy.value, tracks]); + + final filteredTracks = useMemoized(() { + if (searchController.text.isEmpty) { + return sortedTracks; + } + return sortedTracks + .map((e) => ( + weightedRatio( + "${e.name} - ${e.artists?.asString() ?? ""}", + searchController.text, + ), + e, + )) + .toList() + .sorted( + (a, b) => b.$1.compareTo(a.$1), + ) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() + .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 { + ref.invalidate(localTracksProvider); + }, + child: InterScrollbar( + controller: controller, + 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( + playlist: playlist, + track: FakeData.track, + index: index, + ); + } + + final track = filteredTracks[index]; + return TrackTile( + index: index, + playlist: playlist, + track: track, + userPlaylist: false, + onTap: () async { + await playLocalTracks( + ref, + sortedTracks, + currentTrack: track, + ); + }, + ); + }, + ), + ), + ), + ), + ); + }, + loading: () => Expanded( + child: Skeletonizer( + enabled: true, + child: ListView.builder( + itemCount: 5, + itemBuilder: (context, index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), + ), + ), + ), + error: (error, stackTrace) => + Text(error.toString() + stackTrace.toString()), + ) + ], + ) + ), + ); + } +} diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index 76ef8e3e..3092ed03 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -3,6 +3,7 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index a537038e..d34586f3 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -69,6 +69,11 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(downloadLocation: downloadDir); } + void setLocalLibraryLocation(List localLibraryDirs) { + //if (localLibraryDir.isEmpty) return; + state = state.copyWith(localLibraryLocation: localLibraryDirs); + } + void setLayoutMode(LayoutMode mode) { state = state.copyWith(layoutMode: mode); } diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index 67eb18a2..56f66375 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -84,6 +84,7 @@ class UserPreferences with _$UserPreferences { @Default(Market.US) Market recommendationMarket, @Default(SearchMode.youtube) SearchMode searchMode, @Default("") String downloadLocation, + @Default([]) List localLibraryLocation, @Default("https://pipedapi.kavin.rocks") String pipedInstance, @Default(ThemeMode.system) ThemeMode themeMode, @Default(AudioSource.youtube) AudioSource audioSource, diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart index 94015d37..89c7210a 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -43,6 +43,7 @@ mixin _$UserPreferences { Market get recommendationMarket => throw _privateConstructorUsedError; SearchMode get searchMode => throw _privateConstructorUsedError; String get downloadLocation => throw _privateConstructorUsedError; + List get localLibraryLocation => throw _privateConstructorUsedError; String get pipedInstance => throw _privateConstructorUsedError; ThemeMode get themeMode => throw _privateConstructorUsedError; AudioSource get audioSource => throw _privateConstructorUsedError; @@ -88,6 +89,7 @@ abstract class $UserPreferencesCopyWith<$Res> { Market recommendationMarket, SearchMode searchMode, String downloadLocation, + List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, @@ -126,6 +128,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> Object? recommendationMarket = null, Object? searchMode = null, Object? downloadLocation = null, + Object? localLibraryLocation = null, Object? pipedInstance = null, Object? themeMode = null, Object? audioSource = null, @@ -196,6 +199,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> ? _value.downloadLocation : downloadLocation // ignore: cast_nullable_to_non_nullable as String, + localLibraryLocation: null == localLibraryLocation + ? _value.localLibraryLocation + : localLibraryLocation // ignore: cast_nullable_to_non_nullable + as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -264,6 +271,7 @@ abstract class _$$UserPreferencesImplCopyWith<$Res> Market recommendationMarket, SearchMode searchMode, String downloadLocation, + List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, @@ -300,6 +308,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> Object? recommendationMarket = null, Object? searchMode = null, Object? downloadLocation = null, + Object? localLibraryLocation = null, Object? pipedInstance = null, Object? themeMode = null, Object? audioSource = null, @@ -370,6 +379,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> ? _value.downloadLocation : downloadLocation // ignore: cast_nullable_to_non_nullable as String, + localLibraryLocation: null == localLibraryLocation + ? _value._localLibraryLocation + : localLibraryLocation // ignore: cast_nullable_to_non_nullable + as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -433,6 +446,7 @@ class _$UserPreferencesImpl implements _UserPreferences { this.recommendationMarket = Market.US, this.searchMode = SearchMode.youtube, this.downloadLocation = "", + final List localLibraryLocation = const [], this.pipedInstance = "https://pipedapi.kavin.rocks", this.themeMode = ThemeMode.system, this.audioSource = AudioSource.youtube, @@ -440,7 +454,8 @@ class _$UserPreferencesImpl implements _UserPreferences { this.downloadMusicCodec = SourceCodecs.m4a, this.discordPresence = true, this.endlessPlayback = true, - this.enableConnect = false}); + this.enableConnect = false}) + : _localLibraryLocation = localLibraryLocation; factory _$UserPreferencesImpl.fromJson(Map json) => _$$UserPreferencesImplFromJson(json); @@ -496,6 +511,16 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final String downloadLocation; + final List _localLibraryLocation; + @override + @JsonKey() + List get localLibraryLocation { + if (_localLibraryLocation is EqualUnmodifiableListView) + return _localLibraryLocation; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_localLibraryLocation); + } + @override @JsonKey() final String pipedInstance; @@ -523,7 +548,7 @@ class _$UserPreferencesImpl implements _UserPreferences { @override String toString() { - return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; + return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, localLibraryLocation: $localLibraryLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; } @override @@ -560,6 +585,8 @@ class _$UserPreferencesImpl implements _UserPreferences { other.searchMode == searchMode) && (identical(other.downloadLocation, downloadLocation) || other.downloadLocation == downloadLocation) && + const DeepCollectionEquality() + .equals(other._localLibraryLocation, _localLibraryLocation) && (identical(other.pipedInstance, pipedInstance) || other.pipedInstance == pipedInstance) && (identical(other.themeMode, themeMode) || @@ -597,6 +624,7 @@ class _$UserPreferencesImpl implements _UserPreferences { recommendationMarket, searchMode, downloadLocation, + const DeepCollectionEquality().hash(_localLibraryLocation), pipedInstance, themeMode, audioSource, @@ -647,6 +675,7 @@ abstract class _UserPreferences implements UserPreferences { final Market recommendationMarket, final SearchMode searchMode, final String downloadLocation, + final List localLibraryLocation, final String pipedInstance, final ThemeMode themeMode, final AudioSource audioSource, @@ -698,6 +727,8 @@ abstract class _UserPreferences implements UserPreferences { @override String get downloadLocation; @override + List get localLibraryLocation; + @override String get pipedInstance; @override ThemeMode get themeMode; diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index 930b1dd1..95ed4b03 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -44,6 +44,10 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? SearchMode.youtube, downloadLocation: json['downloadLocation'] as String? ?? "", + localLibraryLocation: (json['localLibraryLocation'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], pipedInstance: json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks", themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? @@ -81,6 +85,7 @@ Map _$$UserPreferencesImplToJson( 'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!, 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, 'downloadLocation': instance.downloadLocation, + 'localLibraryLocation': instance.localLibraryLocation, 'pipedInstance': instance.pipedInstance, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 6dfdd740..2f61edd6 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) system_theme_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin"); system_theme_plugin_register_with_registrar(system_theme_registrar); + g_autoptr(FlPluginRegistrar) system_tray_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "SystemTrayPlugin"); + system_tray_plugin_register_with_registrar(system_tray_registrar); g_autoptr(FlPluginRegistrar) tray_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); tray_manager_plugin_register_with_registrar(tray_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 93ffd3e9..48c7e0ca 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_linux screen_retriever system_theme + system_tray tray_manager url_launcher_linux window_manager diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 84f39341..0057db14 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -21,6 +21,7 @@ import screen_retriever import shared_preferences_foundation import sqflite import system_theme +import system_tray import tray_manager import url_launcher_macos import window_manager @@ -43,6 +44,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) + SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index df623b9e..61de3f25 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1455,12 +1455,13 @@ packages: source: hosted version: "1.0.9" media_kit_native_event_loop: - dependency: transitive + dependency: "direct overridden" description: - name: media_kit_native_event_loop - sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e - url: "https://pub.dev" - source: hosted + path: media_kit_native_event_loop + ref: main + resolved-ref: "285f7919bbf4a7d89a62615b14a3766a171ad575" + url: "https://github.com/media-kit/media-kit" + source: git version: "1.0.8" menu_base: dependency: transitive @@ -2156,6 +2157,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" + system_tray: + dependency: "direct overridden" + description: + path: "." + ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c + resolved-ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c + url: "https://github.com/antler119/system_tray" + source: git + version: "2.0.2" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7435e077..dc60abf6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -150,6 +150,17 @@ dev_dependencies: dependency_overrides: uuid: ^4.4.0 + system_tray: + # TODO: remove this when flutter_desktop_tools gets updated + # to use [MenuItemBase] instead of [MenuItem] + git: + url: https://github.com/antler119/system_tray + ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c + media_kit_native_event_loop: # to fix "macro name must be an identifier" + git: + url: https://github.com/media-kit/media-kit + path: media_kit_native_event_loop + ref: main flutter: generate: true diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfee..91b751eb 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,155 @@ -{} \ No newline at end of file +{ + "ar": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "bn": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "ca": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "cs": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "de": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "es": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "fa": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "fr": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "hi": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "it": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "ja": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "ko": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "ne": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "nl": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "pl": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "pt": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "ru": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "th": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "tr": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "uk": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "vi": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + + "zh": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ] +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 57542dec..f2dd9714 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -42,6 +43,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); SystemThemePluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SystemThemePlugin")); + SystemTrayPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SystemTrayPlugin")); TrayManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 6a0c7723..f4e14280 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -13,6 +13,7 @@ list(APPEND FLUTTER_PLUGIN_LIST permission_handler_windows screen_retriever system_theme + system_tray tray_manager url_launcher_windows window_manager From d82261cb25ece63f85af0e40216cf32dccdc9dd5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 23 May 2024 16:56:52 +0600 Subject: [PATCH 081/261] fix: local track not showing up in queue --- lib/components/library/user_local_tracks.dart | 106 +++--- .../shared/track_tile/track_options.dart | 222 ++++++------ .../shared/track_tile/track_tile.dart | 23 +- lib/pages/library/local_folder.dart | 315 +++++++++--------- .../proxy_playlist/player_listeners.dart | 2 +- .../proxy_playlist/proxy_playlist.dart | 13 +- lib/services/audio_player/audio_player.dart | 11 +- untranslated_messages.json | 28 ++ 8 files changed, 385 insertions(+), 335 deletions(-) diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index d5115aaa..ffaae0d9 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -6,32 +6,20 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:collection/collection.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/sort_tracks_dropdown.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; -import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/service_utils.dart'; // ignore: depend_on_referenced_packages import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; @@ -63,7 +51,8 @@ enum SortBy { album, } -final localTracksProvider = FutureProvider>>((ref) async { +final localTracksProvider = + FutureProvider>>((ref) async { try { if (kIsWeb) return {}; final Map> tracks = {}; @@ -82,7 +71,6 @@ final localTracksProvider = FutureProvider>>((ref) for (var location in [downloadLocation, ...localLibraryLocations]) { if (location.isEmpty) continue; final entities = []; - final dir = Directory(location); if (await Directory(location).exists()) { entities.addAll(Directory(location).listSync(recursive: true)); } @@ -110,7 +98,11 @@ final localTracksProvider = FutureProvider>>((ref) ); } - return {"metadata": metadata, "file": file, "art": imageFile.path}; + return { + "metadata": metadata, + "file": file, + "art": imageFile.path + }; } catch (e, stack) { if (e is FfiException) { return {"file": file}; @@ -152,7 +144,6 @@ class UserLocalTracks extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); final preferences = ref.watch(userPreferencesProvider); @@ -163,69 +154,74 @@ class UserLocalTracks extends HookConsumerWidget { ); if (dirStr == null) return; if (preferences.localLibraryLocation.contains(dirStr)) return; - preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]); + preferencesNotifier.setLocalLibraryLocation( + [...preferences.localLibraryLocation, dirStr]); } else { String? dirStr = await getDirectoryPath( initialDirectory: preferences.downloadLocation, ); if (dirStr == null) return; if (preferences.localLibraryLocation.contains(dirStr)) return; - preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation, dirStr]); + preferencesNotifier.setLocalLibraryLocation( + [...preferences.localLibraryLocation, dirStr]); } }, [preferences.localLibraryLocation]); final removeLocalLibraryLocation = useCallback((String location) { if (!preferences.localLibraryLocation.contains(location)) return; - preferencesNotifier.setLocalLibraryLocation([...preferences.localLibraryLocation]..remove(location)); + preferencesNotifier.setLocalLibraryLocation( + [...preferences.localLibraryLocation]..remove(location), + ); }, [preferences.localLibraryLocation]); // This is just to pre-load the tracks. // For now, this gets all of them. ref.watch(localTracksProvider); - return Column( - children: [ - Padding( + return Column(children: [ + Padding( padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const SizedBox(width: 5), - TextButton.icon( - icon: const Icon(SpotubeIcons.folderAdd), - label: Text(context.l10n.add_library_location), - onPressed: addLocalLibraryLocation, - ) - ] - ) - ), - Expanded( - child: ListView.builder( - itemCount: preferences.localLibraryLocation.length+1, + child: Row(children: [ + const SizedBox(width: 5), + TextButton.icon( + icon: const Icon(SpotubeIcons.folderAdd), + label: Text(context.l10n.add_library_location), + onPressed: addLocalLibraryLocation, + ) + ])), + Expanded( + child: ListView.builder( + itemCount: preferences.localLibraryLocation.length + 1, itemBuilder: (context, index) { late final String location; if (index == 0) { location = preferences.downloadLocation; } else { - location = preferences.localLibraryLocation[index-1]; + location = preferences.localLibraryLocation[index - 1]; } return ListTile( - title: preferences.downloadLocation != location ? Text(location) - : Text(context.l10n.downloads), - trailing: preferences.downloadLocation != location ? Tooltip( - message: context.l10n.remove_library_location, - child: IconButton( - icon: Icon(SpotubeIcons.folderRemove, color: Colors.red[400]), - onPressed: () => removeLocalLibraryLocation(location), - ), - ) : null, - onTap: () async { - context.go("/library/local${location == preferences.downloadLocation ? "?downloads=1" : ""}", extra: location); - } - ); - } - ), - ), - ] - ); + title: preferences.downloadLocation != location + ? Text(location) + : Text(context.l10n.downloads), + trailing: preferences.downloadLocation != location + ? Tooltip( + message: context.l10n.remove_library_location, + child: IconButton( + icon: Icon(SpotubeIcons.folderRemove, + color: Colors.red[400]), + onPressed: () => + removeLocalLibraryLocation(location), + ), + ) + : null, + onTap: () async { + context.go( + "/library/local${location == preferences.downloadLocation ? "?downloads=1" : ""}", + extra: location, + ); + }); + }), + ), + ]); } } diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index a9ec36b9..c917ebaa 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -197,6 +197,8 @@ class TrackOptions extends HookConsumerWidget { return downloadManager.getProgressNotifier(spotubeTrack); }); + final isLocalTrack = track is LocalTrack; + final adaptivePopSheetList = AdaptivePopSheetList( onSelected: (value) async { switch (value) { @@ -314,118 +316,120 @@ class TrackOptions extends HookConsumerWidget { ), ), ], - children: switch (track.runtimeType) { - LocalTrack() => [ - PopSheetEntry( - value: TrackOptionValue.delete, - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), - ) - ], - _ => [ - 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, - leading: const Icon(SpotubeIcons.queueAdd), - title: Text(context.l10n.add_to_queue), - ), - PopSheetEntry( - value: TrackOptionValue.playNext, - leading: const Icon(SpotubeIcons.lightning), - title: Text(context.l10n.play_next), - ), - ] else - PopSheetEntry( - value: TrackOptionValue.removeFromQueue, - enabled: playlist.activeTrack?.id != track.id, - leading: const Icon(SpotubeIcons.queueRemove), - title: Text(context.l10n.remove_from_queue), - ), - if (me.asData?.value != null) - PopSheetEntry( - value: TrackOptionValue.favorite, - leading: favorites.isLiked - ? const Icon( - SpotubeIcons.heartFilled, - color: Colors.pink, - ) - : const Icon(SpotubeIcons.heart), - title: Text( - favorites.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - ), - ), - if (auth != null) ...[ - PopSheetEntry( - value: TrackOptionValue.startRadio, - leading: const Icon(SpotubeIcons.radio), - title: Text(context.l10n.start_a_radio), - ), - PopSheetEntry( - value: TrackOptionValue.addToPlaylist, - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - ), - ], - if (userPlaylist && auth != null) - PopSheetEntry( - value: TrackOptionValue.removeFromPlaylist, - leading: const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), - ), - PopSheetEntry( - value: TrackOptionValue.download, - enabled: !isInQueue, - leading: isInQueue - ? HookBuilder(builder: (context) { - final progress = useListenable(progressNotifier!); - return CircularProgressIndicator( - value: progress.value, - ); - }) - : const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_track), + children: [ + if (isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.delete, + leading: const Icon(SpotubeIcons.trash), + title: Text(context.l10n.delete), + ), + 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, + leading: const Icon(SpotubeIcons.queueAdd), + title: Text(context.l10n.add_to_queue), + ), + PopSheetEntry( + value: TrackOptionValue.playNext, + leading: const Icon(SpotubeIcons.lightning), + title: Text(context.l10n.play_next), + ), + ] else + PopSheetEntry( + value: TrackOptionValue.removeFromQueue, + enabled: playlist.activeTrack?.id != track.id, + leading: const Icon(SpotubeIcons.queueRemove), + title: Text(context.l10n.remove_from_queue), + ), + if (me.asData?.value != null && !isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.favorite, + leading: favorites.isLiked + ? const Icon( + SpotubeIcons.heartFilled, + color: Colors.pink, + ) + : const Icon(SpotubeIcons.heart), + title: Text( + favorites.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, ), - PopSheetEntry( - value: TrackOptionValue.blacklist, - leading: const Icon(SpotubeIcons.playlistRemove), - iconColor: !isBlackListed ? Colors.red[400] : null, - textColor: !isBlackListed ? Colors.red[400] : null, - title: Text( - isBlackListed - ? context.l10n.remove_from_blacklist - : context.l10n.add_to_blacklist, - ), + ), + if (auth != null && !isLocalTrack) ...[ + PopSheetEntry( + value: TrackOptionValue.startRadio, + leading: const Icon(SpotubeIcons.radio), + title: Text(context.l10n.start_a_radio), + ), + PopSheetEntry( + value: TrackOptionValue.addToPlaylist, + leading: const Icon(SpotubeIcons.playlistAdd), + title: Text(context.l10n.add_to_playlist), + ), + ], + if (userPlaylist && auth != null && !isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.removeFromPlaylist, + leading: const Icon(SpotubeIcons.removeFilled), + title: Text(context.l10n.remove_from_playlist), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.download, + enabled: !isInQueue, + leading: isInQueue + ? HookBuilder(builder: (context) { + final progress = useListenable(progressNotifier!); + return CircularProgressIndicator( + value: progress.value, + ); + }) + : const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_track), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.blacklist, + leading: const Icon(SpotubeIcons.playlistRemove), + iconColor: !isBlackListed ? Colors.red[400] : null, + textColor: !isBlackListed ? Colors.red[400] : null, + title: Text( + isBlackListed + ? context.l10n.remove_from_blacklist + : context.l10n.add_to_blacklist, ), - PopSheetEntry( - value: TrackOptionValue.share, - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.share, + leading: const Icon(SpotubeIcons.share), + title: Text(context.l10n.share), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.songlink, + leading: Assets.logos.songlinkTransparent.image( + width: 22, + height: 22, + color: colorScheme.onSurface.withOpacity(0.5), ), - PopSheetEntry( - value: TrackOptionValue.songlink, - leading: Assets.logos.songlinkTransparent.image( - width: 22, - height: 22, - color: colorScheme.onSurface.withOpacity(0.5), - ), - title: Text(context.l10n.song_link), - ), - PopSheetEntry( - value: TrackOptionValue.details, - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.details), - ), - ] - }, + title: Text(context.l10n.song_link), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.details, + leading: const Icon(SpotubeIcons.info), + title: Text(context.l10n.details), + ), + ], ); //! This is the most ANTI pattern I've ever done, but it works diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 30912da2..e3aea4de 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -195,19 +195,26 @@ class TrackTile extends HookConsumerWidget { children: [ Expanded( flex: 6, - child: LinkText( - track.name!, - "/track/${track.id}", - push: true, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + child: switch (track) { + LocalTrack() => Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _ => LinkText( + track.name!, + "/track/${track.id}", + push: true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + }, ), if (constrains.mdAndUp) ...[ const SizedBox(width: 8), Expanded( flex: 4, - child: switch (track.runtimeType) { + child: switch (track) { LocalTrack() => Text( track.album!.name!, maxLines: 1, diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart index 89d70e09..7a975935 100644 --- a/lib/pages/library/local_folder.dart +++ b/lib/pages/library/local_folder.dart @@ -1,5 +1,4 @@ import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; @@ -46,14 +45,14 @@ class LocalLibraryPage extends HookConsumerWidget { await playback.jumpToTrack(currentTrack); } } - + @override Widget build(BuildContext context, ref) { final sortBy = useState(SortBy.none); final playlist = ref.watch(proxyPlaylistProvider); final trackSnapshot = ref.watch(localTracksProvider); - final isPlaylistPlaying = - playlist.containsTracks(trackSnapshot.asData?.value.values.flattened.toList() ?? []); + final isPlaylistPlaying = playlist.containsTracks( + trackSnapshot.asData?.value.values.flattened.toList() ?? []); final searchController = useTextEditingController(); useValueListenable(searchController); @@ -61,176 +60,178 @@ class LocalLibraryPage extends HookConsumerWidget { final isFiltering = useState(false); final controller = useScrollController(); - + return SafeArea( bottom: false, child: Scaffold( - appBar: PageWindowTitleBar( - leading: const BackButton(), - centerTitle: true, - title: Text(isDownloads ? context.l10n.downloads : location), - backgroundColor: Colors.transparent, - ), - extendBodyBehindAppBar: true, - body: Column( - children: [ - const SizedBox(height: 56), - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const SizedBox(width: 5), - FilledButton( - onPressed: trackSnapshot.asData?.value != null - ? () async { - if (trackSnapshot.asData?.value.isNotEmpty == true) { - if (!isPlaylistPlaying) { - await playLocalTracks( - ref, - trackSnapshot.asData!.value[location] ?? [], - ); + appBar: PageWindowTitleBar( + leading: const BackButton(), + centerTitle: true, + title: Text(isDownloads ? context.l10n.downloads : location), + backgroundColor: Colors.transparent, + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const SizedBox(width: 5), + FilledButton( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot.asData?.value.isNotEmpty == + true) { + if (!isPlaylistPlaying) { + await playLocalTracks( + ref, + trackSnapshot.asData!.value[location] ?? [], + ); + } } } - } - : null, - child: Row( - children: [ - Text(context.l10n.play), - Icon( - isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, - ) - ], + : null, + child: Row( + children: [ + Text(context.l10n.play), + Icon( + isPlaylistPlaying + ? SpotubeIcons.stop + : SpotubeIcons.play, + ) + ], + ), ), - ), - const Spacer(), - ExpandableSearchButton( - isFiltering: isFiltering.value, - onPressed: (value) => isFiltering.value = value, - searchFocus: searchFocus, - ), - const SizedBox(width: 10), - SortTracksDropdown( - value: sortBy.value, - onChanged: (value) { - sortBy.value = value; - }, - ), - const SizedBox(width: 5), - FilledButton( - child: const Icon(SpotubeIcons.refresh), - onPressed: () { - ref.invalidate(localTracksProvider); - }, - ) - ], + const Spacer(), + ExpandableSearchButton( + isFiltering: isFiltering.value, + onPressed: (value) => isFiltering.value = value, + searchFocus: searchFocus, + ), + const SizedBox(width: 10), + SortTracksDropdown( + value: sortBy.value, + onChanged: (value) { + sortBy.value = value; + }, + ), + const SizedBox(width: 5), + FilledButton( + child: const Icon(SpotubeIcons.refresh), + onPressed: () { + ref.invalidate(localTracksProvider); + }, + ) + ], + ), ), - ), - ExpandableSearchField( - searchController: searchController, - searchFocus: searchFocus, - isFiltering: isFiltering.value, - onChangeFiltering: (value) => isFiltering.value = value, - ), - trackSnapshot.when( - data: (tracks) { - final sortedTracks = useMemoized(() { - return ServiceUtils.sortTracks(tracks[location] ?? [], sortBy.value); - }, [sortBy.value, tracks]); + ExpandableSearchField( + searchController: searchController, + searchFocus: searchFocus, + isFiltering: isFiltering.value, + onChangeFiltering: (value) => isFiltering.value = value, + ), + trackSnapshot.when( + data: (tracks) { + final sortedTracks = useMemoized(() { + return ServiceUtils.sortTracks( + tracks[location] ?? [], sortBy.value); + }, [sortBy.value, tracks]); - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return sortedTracks; + final filteredTracks = useMemoized(() { + if (searchController.text.isEmpty) { + return sortedTracks; + } + return sortedTracks + .map((e) => ( + weightedRatio( + "${e.name} - ${e.artists?.asString() ?? ""}", + searchController.text, + ), + e, + )) + .toList() + .sorted( + (a, b) => b.$1.compareTo(a.$1), + ) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() + .toList(); + }, [searchController.text, sortedTracks]); + + if (!trackSnapshot.isLoading && filteredTracks.isEmpty) { + return const Expanded( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [NotFound()], + ), + ); } - return sortedTracks - .map((e) => ( - weightedRatio( - "${e.name} - ${e.artists?.asString() ?? ""}", - searchController.text, - ), - e, - )) - .toList() - .sorted( - (a, b) => b.$1.compareTo(a.$1), - ) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList() - .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 { - ref.invalidate(localTracksProvider); - }, - child: InterScrollbar( - controller: controller, - 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( - playlist: playlist, - track: FakeData.track, - index: index, - ); - } - - final track = filteredTracks[index]; - return TrackTile( - index: index, - playlist: playlist, - track: track, - userPlaylist: false, - onTap: () async { - await playLocalTracks( - ref, - sortedTracks, - currentTrack: track, + return Expanded( + child: RefreshIndicator( + onRefresh: () async { + ref.invalidate(localTracksProvider); + }, + child: InterScrollbar( + controller: controller, + 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( + playlist: playlist, + track: FakeData.track, + index: index, ); - }, - ); - }, + } + + final track = filteredTracks[index]; + return TrackTile( + index: index, + playlist: playlist, + track: track, + userPlaylist: false, + onTap: () async { + await playLocalTracks( + ref, + sortedTracks, + currentTrack: track, + ); + }, + ); + }, + ), ), ), ), - ), - ); - }, - loading: () => Expanded( - child: Skeletonizer( - enabled: true, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) => TrackTile( - track: FakeData.track, - index: index, - playlist: playlist, + ); + }, + loading: () => Expanded( + child: Skeletonizer( + enabled: true, + child: ListView.builder( + itemCount: 5, + itemBuilder: (context, index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), ), ), ), - ), - error: (error, stackTrace) => - Text(error.toString() + stackTrace.toString()), - ) - ], - ) - ), + error: (error, stackTrace) => + Text(error.toString() + stackTrace.toString()), + ) + ], + )), ); } } diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index f86ad3d4..bf54fa90 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -1,4 +1,4 @@ -// ignore_for_file: invalid_use_of_protected_member +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member import 'dart:async'; diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index f70301ff..b2241ad7 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -45,7 +45,14 @@ class ProxyPlaylist { } bool containsTrack(TrackSimple track) { - return tracks.firstWhereOrNull((element) => element.id == track.id) != null; + return tracks.firstWhereOrNull((element) { + if (element is LocalTrack && track is LocalTrack) { + return element.path == track.path; + } + + return element.id == track.id; + }) != + null; } bool containsTracks(Iterable tracks) { @@ -65,8 +72,8 @@ class ProxyPlaylist { /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { return switch (track.runtimeType) { - LocalTrack() => track.toJson(), - SourcedTrack() => track.toJson(), + LocalTrack() => (track as LocalTrack).toJson(), + SourcedTrack() => (track as SourcedTrack).toJson(), _ => track.toJson(), }; } diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 92de192b..d67652b4 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -13,6 +13,7 @@ import 'package:media_kit/media_kit.dart' as mk; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; @@ -30,12 +31,18 @@ class SpotubeMedia extends mk.Media { : "http://${InternetAddress.loopbackIPv4.address}:${PlaybackServer.port}/stream/${track.id}", extras: { ...?extras, - "track": track.toJson(), + "track": switch (track) { + LocalTrack() => track.toJson(), + SourcedTrack() => track.toJson(), + _ => track.toJson(), + }, }, ); factory SpotubeMedia.fromMedia(mk.Media media) { - final track = Track.fromJson(media.extras?["track"]); + final track = media.uri.startsWith("http") + ? Track.fromJson(media.extras?["track"]) + : LocalTrack.fromJson(media.extras?["track"]); return SpotubeMedia(track); } } diff --git a/untranslated_messages.json b/untranslated_messages.json index 91b751eb..3ea0ca23 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -41,6 +41,13 @@ "local_tab" ], + "eu": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + "fa": [ "local_library", "add_library_location", @@ -48,6 +55,13 @@ "local_tab" ], + "fi": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + "fr": [ "local_library", "add_library_location", @@ -62,6 +76,13 @@ "local_tab" ], + "id": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + "it": [ "local_library", "add_library_location", @@ -76,6 +97,13 @@ "local_tab" ], + "ka": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab" + ], + "ko": [ "local_library", "add_library_location", From fc5bfa089ce2f46ab786565d6750564d704ee7e0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 23 May 2024 21:27:09 +0600 Subject: [PATCH 082/261] feat: local library folder cards --- .../local_folder/local_folder_item.dart | 199 ++++++++++++++++ lib/components/library/user_local_tracks.dart | 214 ++++-------------- .../shared/track_tile/track_options.dart | 2 +- .../configurators/use_get_storage_perms.dart | 2 +- lib/pages/library/local_folder.dart | 1 + .../local_tracks/local_tracks_provider.dart | 125 ++++++++++ .../proxy_playlist/proxy_playlist.dart | 4 +- macos/Podfile.lock | 6 + 8 files changed, 380 insertions(+), 173 deletions(-) create mode 100644 lib/components/library/local_folder/local_folder_item.dart create mode 100644 lib/provider/local_tracks/local_tracks_provider.dart diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/components/library/local_folder/local_folder_item.dart new file mode 100644 index 00000000..281cfc2c --- /dev/null +++ b/lib/components/library/local_folder/local_folder_item.dart @@ -0,0 +1,199 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:path/path.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/extensions/image.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class LocalFolderItem extends HookConsumerWidget { + final String folder; + const LocalFolderItem({super.key, required this.folder}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme) = Theme.of(context); + final mediaQuery = MediaQuery.of(context); + + final lerpValue = useBrightnessValue(.9, .7); + + final downloadFolder = + ref.watch(userPreferencesProvider.select((s) => s.downloadLocation)); + + final isDownloadFolder = folder == downloadFolder; + + final Uri(:pathSegments) = Uri.parse( + folder + .replaceFirst(RegExp(r'^/Volumes/[^/]+/Users/'), "") + .replaceFirst(r'C:\Users\', "") + .replaceFirst(r'/home/', ""), + ); + + // if length > 5, we ... all the middle segments after 2 and the last 2 + final segments = pathSegments.length > 5 + ? [ + ...pathSegments.take(2), + "...", + ...pathSegments.skip(pathSegments.length - 3).toList() + ..removeLast(), + ] + : pathSegments.take(pathSegments.length - 1).toList(); + + final trackSnapshot = ref.watch( + localTracksProvider.select( + (s) => s.whenData((tracks) => tracks[folder]?.take(4).toList()), + ), + ); + + final tracks = trackSnapshot.value ?? []; + + return InkWell( + onTap: () { + if (isDownloadFolder) { + context.go("/library/local?downloads=1", extra: folder); + } else { + context.go( + "/library/local", + extra: folder, + ); + } + }, + borderRadius: BorderRadius.circular(8), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Color.lerp( + colorScheme.surfaceVariant, + colorScheme.surface, + lerpValue, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (tracks.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + SpotubeIcons.folder, + size: mediaQuery.smAndDown + ? 95 + : mediaQuery.mdAndDown + ? 100 + : 142, + ), + ), + ) + else + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: max((tracks.length / 2).ceil(), 2), + ), + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + return UniversalImage( + path: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ); + }, + ), + ), + const Gap(8), + Stack( + children: [ + Center( + child: Text( + isDownloadFolder + ? context.l10n.downloads + : basename(folder), + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + if (!isDownloadFolder) + Align( + alignment: Alignment.topRight, + child: PopupMenuButton( + child: const Padding( + padding: EdgeInsets.all(3), + child: Icon(Icons.more_vert), + ), + itemBuilder: (context) { + return [ + PopupMenuItem( + child: ListTile( + leading: const Icon(SpotubeIcons.folderRemove), + iconColor: colorScheme.error, + title: + Text(context.l10n.remove_library_location), + onTap: () { + final libraryLocations = ref + .read(userPreferencesProvider) + .localLibraryLocation; + ref + .read(userPreferencesProvider.notifier) + .setLocalLibraryLocation( + libraryLocations + .where((e) => e != folder) + .toList(), + ); + }, + ), + ) + ]; + }, + ), + ), + ], + ), + const Spacer(), + Wrap( + spacing: 2, + runSpacing: 2, + children: [ + for (final MapEntry(key: index, value: segment) + in segments.asMap().entries) + Text.rich( + TextSpan( + children: [ + if (index != 0) + TextSpan( + text: "/ ", + style: TextStyle(color: colorScheme.primary), + ), + TextSpan(text: segment), + ], + ), + style: TextStyle( + fontSize: 10, + color: colorScheme.tertiary, + ), + ), + ], + ), + const Spacer(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index ffaae0d9..c0d63380 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -1,44 +1,18 @@ -import 'dart:io'; - -import 'package:catcher_2/catcher_2.dart'; import 'package:file_picker/file_picker.dart'; import 'package:file_selector/file_selector.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -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:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/library/local_folder/local_folder_item.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; // ignore: depend_on_referenced_packages -import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; - -const supportedAudioTypes = [ - "audio/webm", - "audio/ogg", - "audio/mpeg", - "audio/mp4", - "audio/opus", - "audio/wav", - "audio/aac", -]; - -const imgMimeToExt = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/webp": ".webp", - "image/gif": ".gif", -}; enum SortBy { none, @@ -51,94 +25,6 @@ enum SortBy { album, } -final localTracksProvider = - FutureProvider>>((ref) async { - try { - if (kIsWeb) return {}; - final Map> tracks = {}; - - final downloadLocation = ref.watch( - userPreferencesProvider.select((s) => s.downloadLocation), - ); - final downloadDir = Directory(downloadLocation); - if (!await downloadDir.exists()) { - await downloadDir.create(recursive: true); - } - final localLibraryLocations = ref.watch( - userPreferencesProvider.select((s) => s.localLibraryLocation), - ); - - for (var location in [downloadLocation, ...localLibraryLocations]) { - if (location.isEmpty) continue; - final entities = []; - if (await Directory(location).exists()) { - entities.addAll(Directory(location).listSync(recursive: true)); - } - - final filesWithMetadata = (await Future.wait( - entities.map((e) => File(e.path)).where((file) { - final mimetype = lookupMimeType(file.path); - return mimetype != null && supportedAudioTypes.contains(mimetype); - }).map( - (file) async { - try { - final metadata = await MetadataGod.readMetadata(file: file.path); - - final imageFile = File(join( - (await getTemporaryDirectory()).path, - "spotube", - basenameWithoutExtension(file.path) + - imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, - )); - if (!await imageFile.exists() && metadata.picture != null) { - await imageFile.create(recursive: true); - await imageFile.writeAsBytes( - metadata.picture?.data ?? [], - mode: FileMode.writeOnly, - ); - } - - return { - "metadata": metadata, - "file": file, - "art": imageFile.path - }; - } catch (e, stack) { - if (e is FfiException) { - return {"file": file}; - } - Catcher2.reportCheckedError(e, stack); - return {}; - } - }, - ), - )) - .where((e) => e.isNotEmpty) - .toList(); - - // ignore: no_leading_underscores_for_local_identifiers - final _tracks = filesWithMetadata - .map( - (fileWithMetadata) => LocalTrack.fromTrack( - track: Track().fromFile( - fileWithMetadata["file"], - metadata: fileWithMetadata["metadata"], - art: fileWithMetadata["art"], - ), - path: fileWithMetadata["file"].path, - ), - ) - .toList(); - - tracks[location] = _tracks; - } - return tracks; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - return {}; - } -}); - class UserLocalTracks extends HookConsumerWidget { const UserLocalTracks({super.key}); @@ -167,61 +53,49 @@ class UserLocalTracks extends HookConsumerWidget { } }, [preferences.localLibraryLocation]); - final removeLocalLibraryLocation = useCallback((String location) { - if (!preferences.localLibraryLocation.contains(location)) return; - preferencesNotifier.setLocalLibraryLocation( - [...preferences.localLibraryLocation]..remove(location), - ); - }, [preferences.localLibraryLocation]); - // This is just to pre-load the tracks. // For now, this gets all of them. ref.watch(localTracksProvider); - return Column(children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Row(children: [ - const SizedBox(width: 5), - TextButton.icon( - icon: const Icon(SpotubeIcons.folderAdd), - label: Text(context.l10n.add_library_location), - onPressed: addLocalLibraryLocation, - ) - ])), - Expanded( - child: ListView.builder( - itemCount: preferences.localLibraryLocation.length + 1, - itemBuilder: (context, index) { - late final String location; - if (index == 0) { - location = preferences.downloadLocation; - } else { - location = preferences.localLibraryLocation[index - 1]; - } - return ListTile( - title: preferences.downloadLocation != location - ? Text(location) - : Text(context.l10n.downloads), - trailing: preferences.downloadLocation != location - ? Tooltip( - message: context.l10n.remove_library_location, - child: IconButton( - icon: Icon(SpotubeIcons.folderRemove, - color: Colors.red[400]), - onPressed: () => - removeLocalLibraryLocation(location), - ), - ) - : null, - onTap: () async { - context.go( - "/library/local${location == preferences.downloadLocation ? "?downloads=1" : ""}", - extra: location, - ); - }); - }), - ), - ]); + return LayoutBuilder(builder: (context, constrains) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(SpotubeIcons.folderAdd), + label: Text(context.l10n.add_library_location), + onPressed: addLocalLibraryLocation, + ), + ), + const Gap(8), + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.isXs + ? 210 + : constrains.mdAndDown + ? 280 + : 250, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: preferences.localLibraryLocation.length + 1, + itemBuilder: (context, index) { + return LocalFolderItem( + folder: index == 0 + ? preferences.downloadLocation + : preferences.localLibraryLocation[index - 1], + ); + }, + ), + ), + ], + ), + ); + }); } } diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index c917ebaa..4b383c47 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -8,7 +8,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; @@ -23,6 +22,7 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart index bcc34042..9cccbfe0 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -3,8 +3,8 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/utils/platform.dart'; void useGetStoragePermissions(WidgetRef ref) { diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart index 7a975935..6552bb5b 100644 --- a/lib/pages/library/local_folder.dart +++ b/lib/pages/library/local_folder.dart @@ -16,6 +16,7 @@ import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart new file mode 100644 index 00000000..867774bd --- /dev/null +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +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:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +// ignore: depend_on_referenced_packages +import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; + +const supportedAudioTypes = [ + "audio/webm", + "audio/ogg", + "audio/mpeg", + "audio/mp4", + "audio/opus", + "audio/wav", + "audio/aac", +]; + +const imgMimeToExt = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", + "image/gif": ".gif", +}; + +final localTracksProvider = + FutureProvider>>((ref) async { + try { + if (kIsWeb) return {}; + final Map> tracks = {}; + + final downloadLocation = ref.watch( + userPreferencesProvider.select((s) => s.downloadLocation), + ); + final downloadDir = Directory(downloadLocation); + if (!await downloadDir.exists()) { + await downloadDir.create(recursive: true); + } + final localLibraryLocations = ref.watch( + userPreferencesProvider.select((s) => s.localLibraryLocation), + ); + + for (var location in [downloadLocation, ...localLibraryLocations]) { + if (location.isEmpty) continue; + final entities = []; + if (await Directory(location).exists()) { + try { + entities.addAll(Directory(location).listSync(recursive: true)); + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + } + } + + final filesWithMetadata = (await Future.wait( + entities.map((e) => File(e.path)).where((file) { + final mimetype = lookupMimeType(file.path); + return mimetype != null && supportedAudioTypes.contains(mimetype); + }).map( + (file) async { + try { + final metadata = await MetadataGod.readMetadata(file: file.path); + + final imageFile = File(join( + (await getTemporaryDirectory()).path, + "spotube", + basenameWithoutExtension(file.path) + + imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, + )); + if (!await imageFile.exists() && metadata.picture != null) { + await imageFile.create(recursive: true); + await imageFile.writeAsBytes( + metadata.picture?.data ?? [], + mode: FileMode.writeOnly, + ); + } + + return { + "metadata": metadata, + "file": file, + "art": imageFile.path + }; + } catch (e, stack) { + if (e is FfiException) { + return {"file": file}; + } + Catcher2.reportCheckedError(e, stack); + return {}; + } + }, + ), + )) + .where((e) => e.isNotEmpty) + .toList(); + + // ignore: no_leading_underscores_for_local_identifiers + final _tracks = filesWithMetadata + .map( + (fileWithMetadata) => LocalTrack.fromTrack( + track: Track().fromFile( + fileWithMetadata["file"], + metadata: fileWithMetadata["metadata"], + art: fileWithMetadata["art"], + ), + path: fileWithMetadata["file"].path, + ), + ) + .toList(); + + tracks[location] = _tracks; + } + return tracks; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + return {}; + } +}); diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index b2241ad7..1378c589 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -71,8 +71,10 @@ class ProxyPlaylist { /// To make sure proper instance method is used for JSON serialization /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { - return switch (track.runtimeType) { + return switch (track) { + // ignore: unnecessary_cast LocalTrack() => (track as LocalTrack).toJson(), + // ignore: unnecessary_cast SourcedTrack() => (track as SourcedTrack).toJson(), _ => track.toJson(), }; diff --git a/macos/Podfile.lock b/macos/Podfile.lock index ce2ef233..166bfa71 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -41,6 +41,8 @@ PODS: - FlutterMacOS - system_theme (0.0.1): - FlutterMacOS + - system_tray (0.0.1): + - FlutterMacOS - tray_manager (0.0.1): - FlutterMacOS - url_launcher_macos (0.0.1): @@ -70,6 +72,7 @@ DEPENDENCIES: - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) + - system_tray (from `Flutter/ephemeral/.symlinks/plugins/system_tray/macos`) - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) @@ -118,6 +121,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin system_theme: :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos + system_tray: + :path: Flutter/ephemeral/.symlinks/plugins/system_tray/macos tray_manager: :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos url_launcher_macos: @@ -148,6 +153,7 @@ SPEC CHECKSUMS: shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc + system_tray: e53c972838c69589ff2e77d6d3abfd71332f9e5d tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 From 82307bc030035b03ab1b8d8ec7b24da19a866b12 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 1 Jun 2024 11:40:01 +0600 Subject: [PATCH 083/261] feat: personalized stats based on local music history (#1522) * feat: add playback history provider * feat: implement recently played section * refactor: use route names * feat: add stats summary and top tracks/artists/albums * feat: add top date based filtering * feat: add stream money calculation * refactor: place search in mobile navbar and settings in home appbar * feat: add individual minutes and streams page * feat(stats): add individual minutes and streams page * chore: default period to 1 month * feat: add text to explain user how hypothetical fees are calculated * chore: ensure usage of route names instead of direct paths * cd: add cache key * cd: remove media_kit_event_loop from git --- .github/workflows/spotube-release-binary.yml | 1 + build.yaml | 7 +- lib/collections/fake.dart | 1 - lib/collections/formatters.dart | 8 + lib/collections/intents.dart | 12 +- lib/collections/routes.dart | 114 +++- lib/collections/side_bar_tiles.dart | 77 ++- lib/collections/spotube_icons.dart | 1 + lib/components/album/album_card.dart | 18 +- lib/components/artist/artist_card.dart | 9 +- lib/components/connect/connect_device.dart | 7 +- lib/components/home/sections/feed.dart | 10 +- .../home/sections/friends/friend_item.dart | 22 +- lib/components/home/sections/genres.dart | 12 +- lib/components/home/sections/recent.dart | 32 + .../local_folder/local_folder_item.dart | 16 +- lib/components/playlist/playlist_card.dart | 17 +- lib/components/root/sidebar.dart | 95 ++- .../root/spotube_navigation_bar.dart | 40 +- .../shared/fallbacks/anonymous_fallback.dart | 3 +- .../horizontal_playbutton_card_view.dart | 2 +- lib/components/shared/links/artist_link.dart | 8 +- .../shared/themed_button_tab_bar.dart | 4 +- .../sections/body/track_view_body.dart | 25 +- .../sections/body/track_view_options.dart | 15 + .../sections/header/header_actions.dart | 10 + .../sections/header/header_buttons.dart | 40 +- .../shared/tracks_view/track_view_props.dart | 12 +- lib/components/stats/common/album_item.dart | 53 ++ lib/components/stats/common/artist_item.dart | 39 ++ .../stats/common/playlist_item.dart | 46 ++ lib/components/stats/common/track_item.dart | 49 ++ lib/components/stats/summary/summary.dart | 100 +++ .../stats/summary/summary_card.dart | 86 +++ lib/components/stats/top/albums.dart | 29 + lib/components/stats/top/artists.dart | 27 + lib/components/stats/top/top.dart | 106 +++ lib/components/stats/top/tracks.dart | 31 + lib/extensions/album_simple.dart | 15 - lib/extensions/artist_simple.dart | 12 - lib/extensions/track.dart | 29 - lib/l10n/app_en.arb | 5 +- lib/models/connect/connect.dart | 1 - lib/models/connect/connect.freezed.dart | 498 ++++++++++++-- lib/models/connect/connect.g.dart | 52 +- lib/models/connect/load.dart | 19 +- lib/models/current_playlist.dart | 1 - lib/models/local_track.dart | 4 +- lib/models/source_match.g.dart | 2 +- lib/models/spotify/home_feed.g.dart | 70 +- .../spotify/recommendation_seeds.g.dart | 3 +- lib/models/spotify_friends.g.dart | 39 +- lib/pages/album/album.dart | 4 +- lib/pages/artist/artist.dart | 2 + lib/pages/artist/section/top_tracks.dart | 3 +- lib/pages/connect/connect.dart | 7 +- lib/pages/connect/control/control.dart | 11 +- lib/pages/desktop_login/desktop_login.dart | 2 + lib/pages/desktop_login/login_tutorial.dart | 4 +- .../getting_started/getting_started.dart | 2 + .../getting_started/sections/support.dart | 6 +- lib/pages/home/feed/feed_section.dart | 2 + lib/pages/home/genres/genre_playlists.dart | 2 + lib/pages/home/genres/genres.dart | 10 +- lib/pages/home/home.dart | 43 +- lib/pages/lastfm_login/lastfm_login.dart | 1 + lib/pages/library/library.dart | 2 + lib/pages/library/local_folder.dart | 2 + .../playlist_generate/playlist_generate.dart | 2 + .../playlist_generate_result.dart | 10 +- lib/pages/lyrics/lyrics.dart | 2 + lib/pages/lyrics/mini_lyrics.dart | 2 + lib/pages/mobile_login/mobile_login.dart | 1 + lib/pages/playlist/liked_playlist.dart | 5 +- lib/pages/playlist/playlist.dart | 4 +- lib/pages/profile/profile.dart | 2 + lib/pages/root/root_app.dart | 36 +- lib/pages/search/search.dart | 191 +++--- lib/pages/search/sections/tracks.dart | 2 +- lib/pages/settings/about.dart | 2 + lib/pages/settings/blacklist.dart | 2 + lib/pages/settings/logs.dart | 2 + lib/pages/settings/sections/accounts.dart | 28 +- lib/pages/settings/settings.dart | 3 + lib/pages/stats/albums/albums.dart | 38 ++ lib/pages/stats/artists/artists.dart | 38 ++ lib/pages/stats/fees/fees.dart | 65 ++ lib/pages/stats/minutes/minutes.dart | 44 ++ lib/pages/stats/playlists/playlists.dart | 39 ++ lib/pages/stats/stats.dart | 35 + lib/pages/stats/streams/streams.dart | 44 ++ lib/pages/track/track.dart | 2 + lib/provider/connect/server.dart | 15 +- lib/provider/history/history.dart | 129 ++++ lib/provider/history/recent.dart | 40 ++ lib/provider/history/state.dart | 35 + lib/provider/history/state.freezed.dart | 644 ++++++++++++++++++ lib/provider/history/state.g.dart | 55 ++ lib/provider/history/summary.dart | 62 ++ lib/provider/history/top.dart | 95 +++ .../proxy_playlist/player_listeners.dart | 55 +- .../proxy_playlist/proxy_playlist.dart | 1 - .../proxy_playlist_provider.dart | 28 +- .../user_preferences_provider.dart | 1 + .../user_preferences_state.g.dart | 3 +- lib/services/audio_player/audio_player.dart | 1 - .../audio_services/mobile_audio_service.dart | 4 +- lib/services/song_link/song_link.g.dart | 3 +- .../sourced_track/models/source_info.g.dart | 2 +- .../sourced_track/models/source_map.g.dart | 15 +- lib/utils/service_utils.dart | 46 ++ pubspec.lock | 22 +- pubspec.yaml | 15 +- untranslated_messages.json | 78 ++- 114 files changed, 3372 insertions(+), 613 deletions(-) create mode 100644 lib/collections/formatters.dart create mode 100644 lib/components/home/sections/recent.dart create mode 100644 lib/components/stats/common/album_item.dart create mode 100644 lib/components/stats/common/artist_item.dart create mode 100644 lib/components/stats/common/playlist_item.dart create mode 100644 lib/components/stats/common/track_item.dart create mode 100644 lib/components/stats/summary/summary.dart create mode 100644 lib/components/stats/summary/summary_card.dart create mode 100644 lib/components/stats/top/albums.dart create mode 100644 lib/components/stats/top/artists.dart create mode 100644 lib/components/stats/top/top.dart create mode 100644 lib/components/stats/top/tracks.dart create mode 100644 lib/pages/stats/albums/albums.dart create mode 100644 lib/pages/stats/artists/artists.dart create mode 100644 lib/pages/stats/fees/fees.dart create mode 100644 lib/pages/stats/minutes/minutes.dart create mode 100644 lib/pages/stats/playlists/playlists.dart create mode 100644 lib/pages/stats/stats.dart create mode 100644 lib/pages/stats/streams/streams.dart create mode 100644 lib/provider/history/history.dart create mode 100644 lib/provider/history/recent.dart create mode 100644 lib/provider/history/state.dart create mode 100644 lib/provider/history/state.freezed.dart create mode 100644 lib/provider/history/state.g.dart create mode 100644 lib/provider/history/summary.dart create mode 100644 lib/provider/history/top.dart diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 0fe1f1ba..694dc1eb 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -66,6 +66,7 @@ jobs: - uses: subosito/flutter-action@v2.12.0 with: cache: true + cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }} flutter-version: ${{ env.FLUTTER_VERSION }} - name: Setup Java if: ${{matrix.platform == 'android'}} diff --git a/build.yaml b/build.yaml index f074d6e1..d83d6a20 100644 --- a/build.yaml +++ b/build.yaml @@ -2,4 +2,9 @@ targets: $default: sources: exclude: - - bin/*.dart \ No newline at end of file + - bin/*.dart + builders: + json_serializable: + options: + any_map: true + explicit_to_json: true diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 4df19dfc..7391d3a0 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -1,5 +1,4 @@ import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; diff --git a/lib/collections/formatters.dart b/lib/collections/formatters.dart new file mode 100644 index 00000000..0aed9e9f --- /dev/null +++ b/lib/collections/formatters.dart @@ -0,0 +1,8 @@ +import 'package:intl/intl.dart'; + +final compactNumberFormatter = NumberFormat.compact(); +final usdFormatter = NumberFormat.compactCurrency( + locale: 'en-US', + symbol: r"$", + decimalDigits: 2, +); diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 5f60959e..579aff18 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -7,6 +7,10 @@ import 'package:go_router/go_router.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/models/logger.dart'; +import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/library/library.dart'; +import 'package:spotube/pages/lyrics/lyrics.dart'; +import 'package:spotube/pages/search/search.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; @@ -67,16 +71,16 @@ class HomeTabAction extends Action { final router = intent.ref.read(routerProvider); switch (intent.tab) { case HomeTabs.browse: - router.go("/"); + router.goNamed(HomePage.name); break; case HomeTabs.search: - router.go("/search"); + router.goNamed(SearchPage.name); break; case HomeTabs.library: - router.go("/library"); + router.goNamed(LibraryPage.name); break; case HomeTabs.lyrics: - router.go("/lyrics"); + router.goNamed(LyricsPage.name); break; } return null; diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 340b816a..dc2e4b7c 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -25,6 +25,13 @@ import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/logs.dart'; +import 'package:spotube/pages/stats/albums/albums.dart'; +import 'package:spotube/pages/stats/artists/artists.dart'; +import 'package:spotube/pages/stats/fees/fees.dart'; +import 'package:spotube/pages/stats/minutes/minutes.dart'; +import 'package:spotube/pages/stats/playlists/playlists.dart'; +import 'package:spotube/pages/stats/stats.dart'; +import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; @@ -51,6 +58,7 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: "/", + name: HomePage.name, redirect: (context, state) async { final authNotifier = ref.read(authenticationProvider.notifier); final json = await authNotifier.box.get(authNotifier.cacheKey); @@ -67,11 +75,13 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: "genres", + name: GenrePage.name, pageBuilder: (context, state) => const SpotubePage(child: GenrePage()), ), GoRoute( path: "genre/:categoryId", + name: GenrePlaylistsPage.name, pageBuilder: (context, state) => SpotubePage( child: GenrePlaylistsPage( category: state.extra as Category, @@ -80,6 +90,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "feeds/:feedId", + name: HomeFeedSectionPage.name, pageBuilder: (context, state) => SpotubePage( child: HomeFeedSectionPage( sectionUri: state.pathParameters["feedId"] as String, @@ -90,56 +101,62 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/search", - name: "Search", + name: SearchPage.name, pageBuilder: (context, state) => const SpotubePage(child: SearchPage()), ), GoRoute( path: "/library", - name: "Library", + name: LibraryPage.name, pageBuilder: (context, state) => const SpotubePage(child: LibraryPage()), routes: [ GoRoute( - path: "generate", - pageBuilder: (context, state) => - const SpotubePage(child: PlaylistGeneratorPage()), - routes: [ - GoRoute( - path: "result", - pageBuilder: (context, state) => SpotubePage( - child: PlaylistGenerateResultPage( - state: state.extra as GeneratePlaylistProviderInput, - ), + path: "generate", + name: PlaylistGeneratorPage.name, + pageBuilder: (context, state) => + const SpotubePage(child: PlaylistGeneratorPage()), + routes: [ + GoRoute( + path: "result", + name: PlaylistGenerateResultPage.name, + pageBuilder: (context, state) => SpotubePage( + child: PlaylistGenerateResultPage( + state: state.extra as GeneratePlaylistProviderInput, ), ), - ]), + ) + ], + ), GoRoute( path: "local", + name: LocalLibraryPage.name, pageBuilder: (context, state) { assert(state.extra is String); return SpotubePage( child: LocalLibraryPage(state.extra as String, - isDownloads: state.uri.queryParameters["downloads"] != null - ), + isDownloads: + state.uri.queryParameters["downloads"] != null), ); }, ), ]), GoRoute( path: "/lyrics", - name: "Lyrics", + name: LyricsPage.name, pageBuilder: (context, state) => const SpotubePage(child: LyricsPage()), ), GoRoute( path: "/settings", + name: SettingsPage.name, pageBuilder: (context, state) => const SpotubePage( child: SettingsPage(), ), routes: [ GoRoute( path: "blacklist", + name: BlackListPage.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const BlackListPage(), ), @@ -147,12 +164,14 @@ final routerProvider = Provider((ref) { if (!kIsWeb) GoRoute( path: "logs", + name: LogsPage.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const LogsPage(), ), ), GoRoute( path: "about", + name: AboutSpotube.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const AboutSpotube(), ), @@ -161,6 +180,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/album/:id", + name: AlbumPage.name, pageBuilder: (context, state) { assert(state.extra is AlbumSimple); return SpotubePage( @@ -170,6 +190,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/artist/:id", + name: ArtistPage.name, pageBuilder: (context, state) { assert(state.pathParameters["id"] != null); return SpotubePage( @@ -178,6 +199,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/playlist/:id", + name: PlaylistPage.name, pageBuilder: (context, state) { assert(state.extra is PlaylistSimple); return SpotubePage( @@ -189,6 +211,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/track/:id", + name: TrackPage.name, pageBuilder: (context, state) { final id = state.pathParameters["id"]!; return SpotubePage( @@ -198,12 +221,14 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/connect", + name: ConnectPage.name, pageBuilder: (context, state) => const SpotubePage( child: ConnectPage(), ), routes: [ GoRoute( path: "control", + name: ConnectControlPage.name, pageBuilder: (context, state) { return const SpotubePage( child: ConnectControlPage(), @@ -214,13 +239,66 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/profile", + name: ProfilePage.name, pageBuilder: (context, state) => const SpotubePage(child: ProfilePage()), + ), + GoRoute( + path: "/stats", + name: StatsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsPage(), + ), + routes: [ + GoRoute( + path: "minutes", + name: StatsMinutesPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsMinutesPage(), + ), + ), + GoRoute( + path: "streams", + name: StatsStreamsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsStreamsPage(), + ), + ), + GoRoute( + path: "fees", + name: StatsStreamFeesPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsStreamFeesPage(), + ), + ), + GoRoute( + path: "artists", + name: StatsArtistsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsArtistsPage(), + ), + ), + GoRoute( + path: "albums", + name: StatsAlbumsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsAlbumsPage(), + ), + ), + GoRoute( + path: "playlists", + name: StatsPlaylistsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsPlaylistsPage(), + ), + ), + ], ) ], ), GoRoute( path: "/mini-player", + name: MiniLyricsPage.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage( child: MiniLyricsPage(prevSize: state.extra as Size), @@ -228,6 +306,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/getting-started", + name: GettingStarting.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( child: GettingStarting(), @@ -235,6 +314,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/login", + name: WebViewLogin.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage( child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), @@ -242,6 +322,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/login-tutorial", + name: LoginTutorial.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( child: LoginTutorial(), @@ -249,6 +330,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/lastfm-login", + name: LastFMLoginPage.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage(child: LastFMLoginPage()), diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart index 551d70d7..4f23c049 100644 --- a/lib/collections/side_bar_tiles.dart +++ b/lib/collections/side_bar_tiles.dart @@ -1,33 +1,82 @@ import 'package:flutter/material.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/library/library.dart'; +import 'package:spotube/pages/lyrics/lyrics.dart'; +import 'package:spotube/pages/search/search.dart'; +import 'package:spotube/pages/stats/stats.dart'; class SideBarTiles { final IconData icon; final String title; final String id; - SideBarTiles({required this.icon, required this.title, required this.id}); + final String name; + + SideBarTiles({ + required this.icon, + required this.title, + required this.id, + required this.name, + }); } List getSidebarTileList(AppLocalizations l10n) => [ - SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), - SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), SideBarTiles( - id: "library", icon: SpotubeIcons.library, title: l10n.library), - SideBarTiles(id: "lyrics", icon: SpotubeIcons.music, title: l10n.lyrics), - ]; - -List getNavbarTileList(AppLocalizations l10n) => [ - SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), - SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), + id: "browse", + name: HomePage.name, + icon: SpotubeIcons.home, + title: l10n.browse, + ), + SideBarTiles( + id: "search", + name: SearchPage.name, + icon: SpotubeIcons.search, + title: l10n.search, + ), SideBarTiles( id: "library", + name: LibraryPage.name, icon: SpotubeIcons.library, title: l10n.library, ), SideBarTiles( - id: "settings", - icon: SpotubeIcons.settings, - title: l10n.settings, - ) + id: "lyrics", + name: LyricsPage.name, + icon: SpotubeIcons.music, + title: l10n.lyrics, + ), + SideBarTiles( + id: "stats", + name: StatsPage.name, + icon: SpotubeIcons.chart, + title: l10n.stats, + ), + ]; + +List getNavbarTileList(AppLocalizations l10n) => [ + SideBarTiles( + id: "browse", + name: HomePage.name, + icon: SpotubeIcons.home, + title: l10n.browse, + ), + SideBarTiles( + id: "search", + name: SearchPage.name, + icon: SpotubeIcons.search, + title: l10n.search, + ), + SideBarTiles( + id: "library", + name: LibraryPage.name, + icon: SpotubeIcons.library, + title: l10n.library, + ), + SideBarTiles( + id: "stats", + name: StatsPage.name, + icon: SpotubeIcons.chart, + title: l10n.stats, + ), ]; diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 2da09f52..a45e581e 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -121,6 +121,7 @@ abstract class SpotubeIcons { static const monitor = FeatherIcons.monitor; static const power = FeatherIcons.power; static const bluetooth = FeatherIcons.bluetooth; + static const chart = FeatherIcons.barChart2; static const folderAdd = FeatherIcons.folderPlus; static const folderRemove = FeatherIcons.folderMinus; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index a71fbf03..7212a574 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -9,7 +9,9 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/pages/album/album.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -32,6 +34,7 @@ class AlbumCard extends HookConsumerWidget { final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.read(playbackHistoryProvider.notifier); bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), @@ -62,7 +65,14 @@ class AlbumCard extends HookConsumerWidget { description: "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", onTap: () { - ServiceUtils.push(context, "/album/${album.id}", extra: album); + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: { + "id": album.id!, + }, + extra: album, + ); }, onPlaybuttonPressed: () async { updating.value = true; @@ -79,14 +89,15 @@ class AlbumCard extends HookConsumerWidget { if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.album( tracks: fetchedTracks, - collectionId: album.id!, + collection: album, ), ); } else { await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); } } finally { updating.value = false; @@ -104,6 +115,7 @@ class AlbumCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); if (context.mounted) { final snackbar = SnackBar( content: Text( diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index cc8485d5..57971ada 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -9,6 +9,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -63,7 +64,13 @@ class ArtistCard extends HookConsumerWidget { ), child: InkWell( onTap: () { - ServiceUtils.push(context, "/artist/${artist.id}"); + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: { + "id": artist.id!, + }, + ); }, borderRadius: radius, child: Padding( diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart index 3ac585df..f4888534 100644 --- a/lib/components/connect/connect_device.dart +++ b/lib/components/connect/connect_device.dart @@ -3,6 +3,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/connect/connect.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -22,7 +23,7 @@ class ConnectDeviceButton extends HookConsumerWidget { width: double.infinity, child: TextButton( onPressed: () { - ServiceUtils.push(context, "/connect"); + ServiceUtils.pushNamed(context, ConnectPage.name); }, style: FilledButton.styleFrom( shape: RoundedRectangleBorder( @@ -59,7 +60,7 @@ class ConnectDeviceButton extends HookConsumerWidget { clipBehavior: Clip.hardEdge, child: InkWell( onTap: () { - ServiceUtils.push(context, "/connect"); + ServiceUtils.pushNamed(context, ConnectPage.name); }, borderRadius: BorderRadius.circular(50), child: Ink( @@ -111,7 +112,7 @@ class ConnectDeviceButton extends HookConsumerWidget { foregroundColor: colorScheme.onPrimary, ), onPressed: () { - ServiceUtils.push(context, "/connect"); + ServiceUtils.pushNamed(context, ConnectPage.name); }, ), ), diff --git a/lib/components/home/sections/feed.dart b/lib/components/home/sections/feed.dart index 793cd2c3..f3f632ce 100644 --- a/lib/components/home/sections/feed.dart +++ b/lib/components/home/sections/feed.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/pages/home/feed/feed_section.dart'; import 'package:spotube/provider/spotify/views/home.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -41,8 +42,13 @@ class HomePageFeedSection extends HookConsumerWidget { child: TextButton.icon( label: const Text("Browse More"), icon: const Icon(SpotubeIcons.angleRight), - onPressed: () => - ServiceUtils.push(context, "/feeds/${section.uri}"), + onPressed: () => ServiceUtils.pushNamed( + context, + HomeFeedSectionPage.name, + pathParameters: { + "feedId": section.uri, + }, + ), ), ), ); diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart index b883e2cc..2b575756 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/components/home/sections/friends/friend_item.dart @@ -6,6 +6,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/spotify_provider.dart'; class FriendItem extends HookConsumerWidget { @@ -57,7 +60,9 @@ class FriendItem extends HookConsumerWidget { text: friend.track.name, recognizer: TapGestureRecognizer() ..onTap = () { - context.push("/track/${friend.track.id}"); + context.pushNamed(TrackPage.name, pathParameters: { + "id": friend.track.id, + }); }, ), const TextSpan(text: " • "), @@ -71,8 +76,12 @@ class FriendItem extends HookConsumerWidget { text: " ${friend.track.artist.name}", recognizer: TapGestureRecognizer() ..onTap = () { - context.push( - "/artist/${friend.track.artist.id}", + context.pushNamed( + ArtistPage.name, + pathParameters: { + "id": friend.track.artist.id, + }, + extra: friend.track.artist, ); }, ), @@ -105,8 +114,11 @@ class FriendItem extends HookConsumerWidget { final album = await spotify.albums.get(friend.track.album.id); if (context.mounted) { - context.push( - "/album/${friend.track.album.id}", + context.pushNamed( + AlbumPage.name, + pathParameters: { + "id": friend.track.album.id, + }, extra: album, ); } diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index 8fbc8bf9..7dfafd5a 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -13,6 +13,8 @@ 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/pages/home/genres/genre_playlists.dart'; +import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HomeGenresSection extends HookConsumerWidget { @@ -50,7 +52,7 @@ class HomeGenresSection extends HookConsumerWidget { textDirection: TextDirection.rtl, child: TextButton.icon( onPressed: () { - context.push('/genres'); + context.pushNamed(GenrePage.name); }, icon: const Icon(SpotubeIcons.angleRight), label: Text( @@ -110,7 +112,13 @@ class HomeGenresSection extends HookConsumerWidget { return InkWell( onTap: () { - context.push('/genre/${category.id}', extra: category); + context.pushNamed( + GenrePlaylistsPage.name, + pathParameters: { + "categoryId": category.id!, + }, + extra: category, + ); }, borderRadius: BorderRadius.circular(8), child: Ink( diff --git a/lib/components/home/sections/recent.dart b/lib/components/home/sections/recent.dart new file mode 100644 index 00000000..0fc5fadf --- /dev/null +++ b/lib/components/home/sections/recent.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/provider/history/recent.dart'; +import 'package:spotube/provider/history/state.dart'; + +class HomeRecentlyPlayedSection extends HookConsumerWidget { + const HomeRecentlyPlayedSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final history = ref.watch(recentlyPlayedItems); + + if (history.isEmpty) { + return const SizedBox(); + } + + return HorizontalPlaybuttonCardView( + title: const Text('Recently Played'), + items: [ + for (final item in history) + if (item is PlaybackHistoryPlaylist) + item.playlist + else if (item is PlaybackHistoryAlbum) + item.album + ], + hasNextPage: false, + isLoadingNextPage: false, + onFetchMore: () {}, + ); + } +} diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/components/library/local_folder/local_folder_item.dart index 281cfc2c..556f09a6 100644 --- a/lib/components/library/local_folder/local_folder_item.dart +++ b/lib/components/library/local_folder/local_folder_item.dart @@ -11,6 +11,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/pages/library/local_folder.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -57,14 +58,13 @@ class LocalFolderItem extends HookConsumerWidget { return InkWell( onTap: () { - if (isDownloadFolder) { - context.go("/library/local?downloads=1", extra: folder); - } else { - context.go( - "/library/local", - extra: folder, - ); - } + context.goNamed( + LocalLibraryPage.name, + queryParameters: { + if (isDownloadFolder) "downloads": 1, + }, + extra: folder, + ); }, borderRadius: BorderRadius.circular(8), child: Ink( diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index ae6f20e5..72e13b26 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -6,7 +6,9 @@ import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -22,6 +24,8 @@ class PlaylistCard extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlistQueue = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.read(playbackHistoryProvider.notifier); + final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; bool isPlaylistPlaying = useMemoized( @@ -55,9 +59,12 @@ class PlaylistCard extends HookConsumerWidget { isOwner: playlist.owner?.id == me.asData?.value.id && me.asData?.value.id != null, onTap: () { - ServiceUtils.push( + ServiceUtils.pushNamed( context, - "/playlist/${playlist.id}", + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, extra: playlist, ); }, @@ -78,14 +85,15 @@ class PlaylistCard extends HookConsumerWidget { if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.playlist( tracks: fetchedTracks, - collectionId: playlist.id!, + collection: playlist, ), ); } else { await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(playlist.id!); + historyNotifier.addPlaylists([playlist]); } } finally { if (context.mounted) { @@ -104,6 +112,7 @@ class PlaylistCard extends HookConsumerWidget { playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(playlist.id!); + historyNotifier.addPlaylists([playlist]); if (context.mounted) { final snackbar = SnackBar( content: Text("Added ${fetchedTracks.length} tracks to queue"), diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index a100ca8e..0e644a89 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -16,6 +16,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; +import 'package:spotube/pages/profile/profile.dart'; +import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -26,13 +28,9 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class Sidebar extends HookConsumerWidget { - final int? selectedIndex; - final void Function(int) onSelectedIndexChanged; final Widget child; const Sidebar({ - required this.selectedIndex, - required this.onSelectedIndexChanged, required this.child, super.key, }); @@ -47,12 +45,9 @@ class Sidebar extends HookConsumerWidget { ); } - static void goToSettings(BuildContext context) { - GoRouter.of(context).go("/settings"); - } - @override Widget build(BuildContext context, WidgetRef ref) { + final routerState = GoRouterState.of(context); final mediaQuery = MediaQuery.of(context); final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; @@ -60,8 +55,17 @@ class Sidebar extends HookConsumerWidget { final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); + final sidebarTileList = useMemoized( + () => getSidebarTileList(context.l10n), + [context.l10n], + ); + + final selectedIndex = sidebarTileList.indexWhere( + (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, + ); + final controller = useSidebarXController( - selectedIndex: selectedIndex ?? 0, + selectedIndex: selectedIndex, extended: mediaQuery.lgAndUp, ); @@ -73,29 +77,6 @@ class Sidebar extends HookConsumerWidget { Color.lerp(bg, Colors.black, 0.45)!, ); - final sidebarTileList = useMemoized( - () => getSidebarTileList(context.l10n), - [context.l10n], - ); - - useEffect(() { - if (controller.selectedIndex != selectedIndex && selectedIndex != null) { - controller.selectIndex(selectedIndex!); - } - return null; - }, [selectedIndex]); - - useEffect(() { - void listener() { - onSelectedIndexChanged(controller.selectedIndex); - } - - controller.addListener(listener); - return () { - controller.removeListener(listener); - }; - }, [controller]); - useEffect(() { if (!context.mounted) return; if (mediaQuery.lgAndUp && !controller.extended) { @@ -106,6 +87,13 @@ class Sidebar extends HookConsumerWidget { return null; }, [mediaQuery, controller]); + useEffect(() { + if (controller.selectedIndex != selectedIndex) { + controller.selectIndex(selectedIndex); + } + return null; + }, [selectedIndex]); + if (layoutMode == LayoutMode.compact || (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { return Scaffold(body: child); @@ -119,23 +107,28 @@ class Sidebar extends HookConsumerWidget { items: sidebarTileList.mapIndexed( (index, e) { return SidebarXItem( - iconWidget: Badge( - backgroundColor: theme.colorScheme.primary, - isLabelVisible: e.title == "Library" && downloadCount > 0, - label: Text( - downloadCount.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 10, + onTap: () { + context.goNamed(e.name); + }, + iconBuilder: (selected, hovered) { + return Badge( + backgroundColor: theme.colorScheme.primary, + isLabelVisible: e.title == "Library" && downloadCount > 0, + label: Text( + downloadCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + ), ), - ), - child: Icon( - e.icon, - color: selectedIndex == index - ? theme.colorScheme.primary - : null, - ), - ), + child: Icon( + e.icon, + color: selected || hovered + ? theme.colorScheme.primary + : null, + ), + ); + }, label: e.title, ); }, @@ -257,7 +250,7 @@ class SidebarFooter extends HookConsumerWidget { if (mediaQuery.mdAndDown) { return IconButton( icon: const Icon(SpotubeIcons.settings), - onPressed: () => Sidebar.goToSettings(context), + onPressed: () => ServiceUtils.navigateNamed(context, SettingsPage.name), ); } @@ -278,7 +271,7 @@ class SidebarFooter extends HookConsumerWidget { Flexible( child: InkWell( onTap: () { - ServiceUtils.push(context, "/profile"); + ServiceUtils.pushNamed(context, ProfilePage.name); }, borderRadius: BorderRadius.circular(30), child: Row( @@ -310,7 +303,7 @@ class SidebarFooter extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.settings), onPressed: () { - Sidebar.goToSettings(context); + ServiceUtils.pushNamed(context, SettingsPage.name); }, ), ], diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 489399e5..e16ad1a8 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -3,55 +3,54 @@ import 'dart:ui'; import 'package:curved_navigation_bar/curved_navigation_bar.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:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/utils/service_utils.dart'; final navigationPanelHeight = StateProvider((ref) => 50); class SpotubeNavigationBar extends HookConsumerWidget { - final int? selectedIndex; - final void Function(int) onSelectedIndexChanged; - const SpotubeNavigationBar({ - required this.selectedIndex, - required this.onSelectedIndexChanged, super.key, }); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); + final routerState = GoRouterState.of(context); + final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; final mediaQuery = MediaQuery.of(context); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - final insideSelectedIndex = useState(selectedIndex ?? 0); - final buttonColor = useBrightnessValue( theme.colorScheme.inversePrimary, theme.colorScheme.primary.withOpacity(0.2), ); - final navbarTileList = - useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]); + final navbarTileList = useMemoized( + () => getNavbarTileList(context.l10n), + [context.l10n], + ); final panelHeight = ref.watch(navigationPanelHeight); - useEffect(() { - if (selectedIndex != null) { - insideSelectedIndex.value = selectedIndex!; - } - return null; - }, [selectedIndex]); + final selectedIndex = useMemoized(() { + final index = navbarTileList.indexWhere( + (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, + ); + + return index == -1 ? 0 : index; + }, [navbarTileList, routerState.matchedLocation]); if (layoutMode == LayoutMode.extended || (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || @@ -91,14 +90,9 @@ class SpotubeNavigationBar extends HookConsumerWidget { }); }, ).toList(), - index: insideSelectedIndex.value, + index: selectedIndex, onTap: (i) { - insideSelectedIndex.value = i; - if (navbarTileList[i].id == "settings") { - Sidebar.goToSettings(context); - return; - } - onSelectedIndexChanged(i); + ServiceUtils.navigateNamed(context, navbarTileList[i].name); }, ), ), diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/shared/fallbacks/anonymous_fallback.dart index 2f06b0b6..5ced6bb6 100644 --- a/lib/components/shared/fallbacks/anonymous_fallback.dart +++ b/lib/components/shared/fallbacks/anonymous_fallback.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -25,7 +26,7 @@ class AnonymousFallback extends ConsumerWidget { const SizedBox(height: 10), FilledButton( child: Text(context.l10n.login_with_spotify), - onPressed: () => ServiceUtils.push(context, "/settings"), + onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name), ) ], ), diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index e142cb35..291950bb 100644 --- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -96,7 +96,7 @@ class HorizontalPlaybuttonCardView extends HookWidget { return switch (item) { PlaylistSimple() => PlaylistCard(item as PlaylistSimple), - AlbumSimple() => AlbumCard(item as Album), + AlbumSimple() => AlbumCard(item as AlbumSimple), Artist() => Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0), diff --git a/lib/components/shared/links/artist_link.dart b/lib/components/shared/links/artist_link.dart index af8b186a..5236a061 100644 --- a/lib/components/shared/links/artist_link.dart +++ b/lib/components/shared/links/artist_link.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/utils/service_utils.dart'; class ArtistLink extends StatelessWidget { @@ -40,9 +41,12 @@ class ArtistLink extends StatelessWidget { if (onRouteChange != null) { onRouteChange?.call("/artist/${artist.value.id}"); } else { - ServiceUtils.push( + ServiceUtils.pushNamed( context, - "/artist/${artist.value.id}", + ArtistPage.name, + pathParameters: { + "id": artist.value.id!, + }, ); } }, diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index 017f04aa..b21ca992 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -5,7 +5,8 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { final List tabs; - const ThemedButtonsTabBar({super.key, required this.tabs}); + final TabController? controller; + const ThemedButtonsTabBar({super.key, required this.tabs, this.controller}); @override Widget build(BuildContext context) { @@ -21,6 +22,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { bottom: 8, ), child: ButtonsTabBar( + controller: controller, radius: 100, decoration: BoxDecoration( color: bgColor, diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index f576ba0a..c3605f33 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -17,6 +17,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @@ -28,6 +29,7 @@ class TrackViewBodySection extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final props = InheritedTrackView.of(context); final trackViewState = ref.watch(trackViewProvider(props.tracks)); @@ -146,11 +148,17 @@ class TrackViewBodySection extends HookConsumerWidget { } else { final tracks = await props.pagination.onFetchAll(); await remotePlayback.load( - WebSocketLoadEventData( - tracks: tracks, - collectionId: props.collectionId, - initialIndex: index, - ), + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: tracks, + collection: props.collection as AlbumSimple, + initialIndex: index, + ) + : WebSocketLoadEventData.playlist( + tracks: tracks, + collection: props.collection as PlaylistSimple, + initialIndex: index, + ), ); } } else { @@ -164,6 +172,13 @@ class TrackViewBodySection extends HookConsumerWidget { autoPlay: true, ); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier + .addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } } } }, diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart index ff92b663..c2adf38b 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; @@ -8,6 +9,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; @@ -23,6 +25,7 @@ class TrackViewBodyOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final audioSource = ref.watch(userPreferencesProvider.select((s) => s.audioSource)); @@ -72,6 +75,12 @@ class TrackViewBodyOptions extends HookConsumerWidget { { playlistNotifier.addTracksAtFirst(selectedTracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } trackViewState.deselectAll(); break; } @@ -79,6 +88,12 @@ class TrackViewBodyOptions extends HookConsumerWidget { { playlistNotifier.addTracks(selectedTracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } trackViewState.deselectAll(); break; } diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart index f6880485..8c1c8e15 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/heart_button.dart'; @@ -9,6 +10,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; class TrackViewHeaderActions extends HookConsumerWidget { @@ -20,6 +22,7 @@ class TrackViewHeaderActions extends HookConsumerWidget { final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); @@ -61,6 +64,13 @@ class TrackViewHeaderActions extends HookConsumerWidget { final tracks = await props.pagination.onFetchAll(); await playlistNotifier.addTracks(tracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier + .addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } }, ), if (props.onHeart != null && auth != null) diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index 50eeb747..5ffff512 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -5,12 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -28,6 +30,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final props = InheritedTrackView.of(context); final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); @@ -52,10 +55,16 @@ class TrackViewHeaderButtons extends HookConsumerWidget { if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( - tracks: allTracks, - collectionId: props.collectionId, - initialIndex: Random().nextInt(allTracks.length)), + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: props.collection as AlbumSimple, + initialIndex: Random().nextInt(allTracks.length)) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: props.collection as PlaylistSimple, + initialIndex: Random().nextInt(allTracks.length), + ), ); await remotePlayback.setShuffle(true); } else { @@ -66,6 +75,11 @@ class TrackViewHeaderButtons extends HookConsumerWidget { ); await audioPlayer.setShuffle(true); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([props.collection as PlaylistSimple]); + } } } finally { isLoading.value = false; @@ -84,14 +98,24 @@ class TrackViewHeaderButtons extends HookConsumerWidget { if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( - tracks: allTracks, - collectionId: props.collectionId, - ), + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: props.collection as AlbumSimple, + ) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: props.collection as PlaylistSimple, + ), ); } else { await playlistNotifier.load(allTracks, autoPlay: true); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([props.collection as PlaylistSimple]); + } } } finally { isLoading.value = false; diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart index a1a07f84..b0a00ae2 100644 --- a/lib/components/shared/tracks_view/track_view_props.dart +++ b/lib/components/shared/tracks_view/track_view_props.dart @@ -39,7 +39,7 @@ class PaginationProps { } class InheritedTrackView extends InheritedWidget { - final String collectionId; + final Object collection; final String title; final String? description; final String image; @@ -55,7 +55,7 @@ class InheritedTrackView extends InheritedWidget { const InheritedTrackView({ super.key, required super.child, - required this.collectionId, + required this.collection, required this.title, this.description, required this.image, @@ -65,7 +65,11 @@ class InheritedTrackView extends InheritedWidget { required this.shareUrl, this.isLiked = false, this.onHeart, - }); + }) : assert(collection is AlbumSimple || collection is PlaylistSimple); + + String get collectionId => collection is AlbumSimple + ? (collection as AlbumSimple).id! + : (collection as PlaylistSimple).id!; @override bool updateShouldNotify(InheritedTrackView oldWidget) { @@ -78,7 +82,7 @@ class InheritedTrackView extends InheritedWidget { oldWidget.onHeart != onHeart || oldWidget.shareUrl != shareUrl || oldWidget.routePath != routePath || - oldWidget.collectionId != collectionId || + oldWidget.collection != collection || oldWidget.child != child; } diff --git a/lib/components/stats/common/album_item.dart b/lib/components/stats/common/album_item.dart new file mode 100644 index 00000000..ccc0fa4e --- /dev/null +++ b/lib/components/stats/common/album_item.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/album/album_card.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsAlbumItem extends StatelessWidget { + final AlbumSimple album; + final Widget info; + const StatsAlbumItem({super.key, required this.album, required this.info}); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (album.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + width: 40, + height: 40, + ), + ), + title: Text(album.name!), + subtitle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("${album.albumType?.formatted} • "), + Flexible( + child: ArtistLink( + artists: album.artists!, + mainAxisAlignment: WrapAlignment.start, + ), + ), + ], + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: {"id": album.id!}, + extra: album, + ); + }, + ); + } +} diff --git a/lib/components/stats/common/artist_item.dart b/lib/components/stats/common/artist_item.dart new file mode 100644 index 00000000..9282d4e1 --- /dev/null +++ b/lib/components/stats/common/artist_item.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsArtistItem extends StatelessWidget { + final Artist artist; + final Widget info; + const StatsArtistItem({ + super.key, + required this.artist, + required this.info, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(artist.name!), + horizontalTitleGap: 8, + leading: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (artist.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: {"id": artist.id!}, + ); + }, + ); + } +} diff --git a/lib/components/stats/common/playlist_item.dart b/lib/components/stats/common/playlist_item.dart new file mode 100644 index 00000000..b07311ab --- /dev/null +++ b/lib/components/stats/common/playlist_item.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsPlaylistItem extends StatelessWidget { + final PlaylistSimple playlist; + final Widget info; + const StatsPlaylistItem( + {super.key, required this.playlist, required this.info}); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (playlist.images).asUrlString( + placeholder: ImagePlaceholder.collection, + ), + width: 40, + height: 40, + ), + ), + title: Text(playlist.name!), + subtitle: Text( + playlist.description!.replaceAll(htmlTagRegexp, ''), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + PlaylistPage.name, + pathParameters: {"id": playlist.id!}, + extra: playlist, + ); + }, + ); + } +} diff --git a/lib/components/stats/common/track_item.dart b/lib/components/stats/common/track_item.dart new file mode 100644 index 00000000..6ba6b886 --- /dev/null +++ b/lib/components/stats/common/track_item.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsTrackItem extends StatelessWidget { + final Track track; + final Widget info; + const StatsTrackItem({ + super.key, + required this.track, + required this.info, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + width: 40, + height: 40, + ), + ), + title: Text(track.name!), + subtitle: ArtistLink( + artists: track.artists!, + mainAxisAlignment: WrapAlignment.start, + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ); + }, + ); + } +} diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart new file mode 100644 index 00000000..61f3bd6c --- /dev/null +++ b/lib/components/stats/summary/summary.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/stats/summary/summary_card.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/pages/stats/albums/albums.dart'; +import 'package:spotube/pages/stats/artists/artists.dart'; +import 'package:spotube/pages/stats/fees/fees.dart'; +import 'package:spotube/pages/stats/minutes/minutes.dart'; +import 'package:spotube/pages/stats/playlists/playlists.dart'; +import 'package:spotube/pages/stats/streams/streams.dart'; +import 'package:spotube/provider/history/summary.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsPageSummarySection extends HookConsumerWidget { + const StatsPageSummarySection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final summary = ref.watch(playbackHistorySummaryProvider); + + return SliverPadding( + padding: const EdgeInsets.all(10), + sliver: SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constrains.isXs + ? 2 + : constrains.smAndDown + ? 3 + : constrains.mdAndDown + ? 4 + : constrains.lgAndDown + ? 5 + : 6, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: constrains.isXs ? 1.3 : 1.5, + ), + delegate: SliverChildListDelegate([ + SummaryCard( + title: summary.duration.inMinutes.toDouble(), + unit: "minutes", + description: 'Listened to music', + color: Colors.purple, + onTap: () { + ServiceUtils.pushNamed(context, StatsMinutesPage.name); + }, + ), + SummaryCard( + title: summary.tracks.toDouble(), + unit: "songs", + description: 'Streamed overall', + color: Colors.lightBlue, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamsPage.name); + }, + ), + SummaryCard.unformatted( + title: usdFormatter.format(summary.fees.toDouble()), + unit: "", + description: 'Owed to artists\nthis month', + color: Colors.green, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); + }, + ), + SummaryCard( + title: summary.artists.toDouble(), + unit: "artist's", + description: 'Music reached you', + color: Colors.yellow, + onTap: () { + ServiceUtils.pushNamed(context, StatsArtistsPage.name); + }, + ), + SummaryCard( + title: summary.albums.toDouble(), + unit: "full albums", + description: 'Got your love', + color: Colors.pink, + onTap: () { + ServiceUtils.pushNamed(context, StatsAlbumsPage.name); + }, + ), + SummaryCard( + title: summary.playlists.toDouble(), + unit: "playlists", + description: 'Were on repeat', + color: Colors.teal, + onTap: () { + ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); + }, + ), + ]), + ); + }), + ); + } +} diff --git a/lib/components/stats/summary/summary_card.dart b/lib/components/stats/summary/summary_card.dart new file mode 100644 index 00000000..243c50e8 --- /dev/null +++ b/lib/components/stats/summary/summary_card.dart @@ -0,0 +1,86 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:spotube/collections/formatters.dart'; + +class SummaryCard extends StatelessWidget { + final String title; + final String unit; + final String description; + final VoidCallback? onTap; + + final MaterialColor color; + + SummaryCard({ + super.key, + required double title, + required this.unit, + required this.description, + required this.color, + this.onTap, + }) : title = compactNumberFormatter.format(title); + + const SummaryCard.unformatted({ + super.key, + required this.title, + required this.unit, + required this.description, + required this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final ThemeData(:textTheme, :brightness) = Theme.of(context); + + final descriptionNewLines = description.split("").where((s) => s == "\n"); + + return Card( + color: brightness == Brightness.dark ? color.shade100 : color.shade50, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AutoSizeText.rich( + TextSpan( + children: [ + TextSpan( + text: title, + style: textTheme.headlineLarge?.copyWith( + color: color.shade900, + ), + ), + TextSpan( + text: " $unit", + style: textTheme.titleMedium?.copyWith( + color: color.shade900, + ), + ), + ], + ), + maxLines: 1, + ), + const Gap(5), + AutoSizeText( + description, + maxLines: description.contains("\n") + ? descriptionNewLines.length + 1 + : 1, + minFontSize: 9, + style: textTheme.labelMedium!.copyWith( + color: color.shade900, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/components/stats/top/albums.dart b/lib/components/stats/top/albums.dart new file mode 100644 index 00000000..51bcf5b0 --- /dev/null +++ b/lib/components/stats/top/albums.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/stats/common/album_item.dart'; +import 'package:spotube/provider/history/top.dart'; + +class TopAlbums extends HookConsumerWidget { + const TopAlbums({super.key}); + + @override + Widget build(BuildContext context, ref) { + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final albums = ref.watch(playbackHistoryTopProvider(historyDuration) + .select((value) => value.albums)); + + return SliverList.builder( + itemCount: albums.length, + itemBuilder: (context, index) { + final album = albums[index]; + return StatsAlbumItem( + album: album.album, + info: Text( + "${compactNumberFormatter.format(album.count)} plays", + ), + ); + }, + ); + } +} diff --git a/lib/components/stats/top/artists.dart b/lib/components/stats/top/artists.dart new file mode 100644 index 00000000..d6d0c98d --- /dev/null +++ b/lib/components/stats/top/artists.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/provider/history/top.dart'; + +class TopArtists extends HookConsumerWidget { + const TopArtists({super.key}); + + @override + Widget build(BuildContext context, ref) { + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final artists = ref.watch(playbackHistoryTopProvider(historyDuration) + .select((value) => value.artists)); + + return SliverList.builder( + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text("${compactNumberFormatter.format(artist.count)} plays"), + ); + }, + ); + } +} diff --git a/lib/components/stats/top/top.dart b/lib/components/stats/top/top.dart new file mode 100644 index 00000000..df1275e8 --- /dev/null +++ b/lib/components/stats/top/top.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/themed_button_tab_bar.dart'; +import 'package:spotube/components/stats/top/albums.dart'; +import 'package:spotube/components/stats/top/artists.dart'; +import 'package:spotube/components/stats/top/tracks.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsPageTopSection extends HookConsumerWidget { + const StatsPageTopSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final tabController = useTabController(initialLength: 3); + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final historyDurationNotifier = + ref.watch(playbackHistoryTopDurationProvider.notifier); + + return SliverMainAxisGroup( + slivers: [ + SliverAppBar( + floating: true, + flexibleSpace: ThemedButtonsTabBar( + controller: tabController, + tabs: const [ + Tab( + child: Padding( + padding: EdgeInsets.all(5), + child: Text("Top Tracks"), + ), + ), + Tab( + child: Padding( + padding: EdgeInsets.all(5), + child: Text("Top Artists"), + ), + ), + Tab( + child: Padding( + padding: EdgeInsets.all(5), + child: Text("Top Albums"), + ), + ), + ], + ), + ), + SliverToBoxAdapter( + child: Align( + alignment: Alignment.centerRight, + child: DropdownButton( + style: Theme.of(context).textTheme.bodySmall!, + isDense: true, + padding: const EdgeInsets.all(4), + borderRadius: BorderRadius.circular(4), + underline: const SizedBox(), + value: historyDuration, + onChanged: (value) { + if (value == null) return; + historyDurationNotifier.update((_) => value); + }, + icon: const Icon(Icons.arrow_drop_down), + items: const [ + DropdownMenuItem( + value: HistoryDuration.days7, + child: Text("This week"), + ), + DropdownMenuItem( + value: HistoryDuration.days30, + child: Text("This month"), + ), + DropdownMenuItem( + value: HistoryDuration.months6, + child: Text("Last 6 months"), + ), + DropdownMenuItem( + value: HistoryDuration.year, + child: Text("This year"), + ), + DropdownMenuItem( + value: HistoryDuration.years2, + child: Text("Last 2 years"), + ), + DropdownMenuItem( + value: HistoryDuration.allTime, + child: Text("All time"), + ), + ], + ), + ), + ), + ListenableBuilder( + listenable: tabController, + builder: (context, _) { + return switch (tabController.index) { + 1 => const TopArtists(), + 2 => const TopAlbums(), + _ => const TopTracks(), + }; + }, + ), + ], + ); + } +} diff --git a/lib/components/stats/top/tracks.dart b/lib/components/stats/top/tracks.dart new file mode 100644 index 00000000..bffa4ecd --- /dev/null +++ b/lib/components/stats/top/tracks.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/provider/history/top.dart'; + +class TopTracks extends HookConsumerWidget { + const TopTracks({super.key}); + + @override + Widget build(BuildContext context, ref) { + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final tracks = ref.watch( + playbackHistoryTopProvider(historyDuration) + .select((value) => value.tracks), + ); + + return SliverList.builder( + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + return StatsTrackItem( + track: track.track, + info: Text( + "${compactNumberFormatter.format(track.count)} plays", + ), + ); + }, + ); + } +} diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart index 7c8ae09e..5678390c 100644 --- a/lib/extensions/album_simple.dart +++ b/lib/extensions/album_simple.dart @@ -1,21 +1,6 @@ import 'package:spotify/spotify.dart'; extension AlbumExtensions on AlbumSimple { - Map toJson() { - return { - "albumType": albumType?.name, - "id": id, - "name": name, - "images": images - ?.map((image) => { - "height": image.height, - "url": image.url, - "width": image.width, - }) - .toList(), - }; - } - Album toAlbum() { Album album = Album(); album.albumType = albumType; diff --git a/lib/extensions/artist_simple.dart b/lib/extensions/artist_simple.dart index 6a80300e..7997355d 100644 --- a/lib/extensions/artist_simple.dart +++ b/lib/extensions/artist_simple.dart @@ -1,17 +1,5 @@ import 'package:spotify/spotify.dart'; -extension ArtistJson on ArtistSimple { - Map toJson() { - return { - "href": href, - "id": id, - "name": name, - "type": type, - "uri": uri, - }; - } -} - extension ArtistExtension on List { String asString() { return map((e) => e.name?.replaceAll(",", " ")).join(", "); diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 9755179d..02c0c492 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -3,8 +3,6 @@ import 'dart:io'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/album_simple.dart'; -import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; extension TrackExtensions on Track { @@ -39,33 +37,6 @@ extension TrackExtensions on Track { return this; } - - Map toJson() { - return TrackExtensions.trackToJson(this); - } - - static Map trackToJson(Track track) { - return { - "album": track.album?.toJson(), - "artists": track.artists?.map((artist) => artist.toJson()).toList(), - "available_markets": track.availableMarkets?.map((e) => e.name).toList(), - "disc_number": track.discNumber, - "duration_ms": track.durationMs, - "explicit": track.explicit, - // "external_ids"track.: externalIds, - // "external_urls"track.: externalUrls, - "href": track.href, - "id": track.id, - "is_playable": track.isPlayable, - // "linked_from"track.: linkedFrom, - "name": track.name, - "popularity": track.popularity, - "preview_rrl": track.previewUrl, - "track_number": track.trackNumber, - "type": track.type, - "uri": track.uri, - }; - } } extension TrackSimpleExtensions on TrackSimple { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a90fd35e..04fc8566 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -324,5 +324,6 @@ "select": "Select", "connect_client_alert": "You're being controlled by {client}", "this_device": "This Device", - "remote": "Remote" -} + "remote": "Remote", + "stats": "Stats" +} \ No newline at end of file diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart index efb37315..28386050 100644 --- a/lib/models/connect/connect.dart +++ b/lib/models/connect/connect.dart @@ -5,7 +5,6 @@ import 'dart:convert'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart index face800e..088cfbd1 100644 --- a/lib/models/connect/connect.freezed.dart +++ b/lib/models/connect/connect.freezed.dart @@ -16,16 +16,89 @@ final _privateConstructorUsedError = UnsupportedError( WebSocketLoadEventData _$WebSocketLoadEventDataFromJson( Map json) { - return _WebSocketLoadEventData.fromJson(json); + switch (json['runtimeType']) { + case 'playlist': + return WebSocketLoadEventDataPlaylist.fromJson(json); + case 'album': + return WebSocketLoadEventDataAlbum.fromJson(json); + + default: + throw CheckedFromJsonException( + json, + 'runtimeType', + 'WebSocketLoadEventData', + 'Invalid union type "${json['runtimeType']}"!'); + } } /// @nodoc mixin _$WebSocketLoadEventData { @JsonKey(name: 'tracks', toJson: _tracksJson) List get tracks => throw _privateConstructorUsedError; - String? get collectionId => throw _privateConstructorUsedError; + Object? get collection => throw _privateConstructorUsedError; int? get initialIndex => throw _privateConstructorUsedError; - + @optionalTypeArgs + TResult when({ + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, + required TResult Function(WebSocketLoadEventDataAlbum value) album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult? Function(WebSocketLoadEventDataAlbum value)? album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult Function(WebSocketLoadEventDataAlbum value)? album, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) $WebSocketLoadEventDataCopyWith get copyWith => @@ -40,7 +113,6 @@ abstract class $WebSocketLoadEventDataCopyWith<$Res> { @useResult $Res call( {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - String? collectionId, int? initialIndex}); } @@ -59,7 +131,6 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, @override $Res call({ Object? tracks = null, - Object? collectionId = freezed, Object? initialIndex = freezed, }) { return _then(_value.copyWith( @@ -67,10 +138,6 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, ? _value.tracks : tracks // ignore: cast_nullable_to_non_nullable as List, - collectionId: freezed == collectionId - ? _value.collectionId - : collectionId // ignore: cast_nullable_to_non_nullable - as String?, initialIndex: freezed == initialIndex ? _value.initialIndex : initialIndex // ignore: cast_nullable_to_non_nullable @@ -80,46 +147,46 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, } /// @nodoc -abstract class _$$WebSocketLoadEventDataImplCopyWith<$Res> +abstract class _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> implements $WebSocketLoadEventDataCopyWith<$Res> { - factory _$$WebSocketLoadEventDataImplCopyWith( - _$WebSocketLoadEventDataImpl value, - $Res Function(_$WebSocketLoadEventDataImpl) then) = - __$$WebSocketLoadEventDataImplCopyWithImpl<$Res>; + factory _$$WebSocketLoadEventDataPlaylistImplCopyWith( + _$WebSocketLoadEventDataPlaylistImpl value, + $Res Function(_$WebSocketLoadEventDataPlaylistImpl) then) = + __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>; @override @useResult $Res call( {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - String? collectionId, + PlaylistSimple? collection, int? initialIndex}); } /// @nodoc -class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> +class __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res> extends _$WebSocketLoadEventDataCopyWithImpl<$Res, - _$WebSocketLoadEventDataImpl> - implements _$$WebSocketLoadEventDataImplCopyWith<$Res> { - __$$WebSocketLoadEventDataImplCopyWithImpl( - _$WebSocketLoadEventDataImpl _value, - $Res Function(_$WebSocketLoadEventDataImpl) _then) + _$WebSocketLoadEventDataPlaylistImpl> + implements _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> { + __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl( + _$WebSocketLoadEventDataPlaylistImpl _value, + $Res Function(_$WebSocketLoadEventDataPlaylistImpl) _then) : super(_value, _then); @pragma('vm:prefer-inline') @override $Res call({ Object? tracks = null, - Object? collectionId = freezed, + Object? collection = freezed, Object? initialIndex = freezed, }) { - return _then(_$WebSocketLoadEventDataImpl( + return _then(_$WebSocketLoadEventDataPlaylistImpl( tracks: null == tracks ? _value._tracks : tracks // ignore: cast_nullable_to_non_nullable as List, - collectionId: freezed == collectionId - ? _value.collectionId - : collectionId // ignore: cast_nullable_to_non_nullable - as String?, + collection: freezed == collection + ? _value.collection + : collection // ignore: cast_nullable_to_non_nullable + as PlaylistSimple?, initialIndex: freezed == initialIndex ? _value.initialIndex : initialIndex // ignore: cast_nullable_to_non_nullable @@ -130,16 +197,21 @@ class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { - _$WebSocketLoadEventDataImpl( +class _$WebSocketLoadEventDataPlaylistImpl + extends WebSocketLoadEventDataPlaylist { + _$WebSocketLoadEventDataPlaylistImpl( {@JsonKey(name: 'tracks', toJson: _tracksJson) required final List tracks, - this.collectionId, - this.initialIndex}) - : _tracks = tracks; + this.collection, + this.initialIndex, + final String? $type}) + : _tracks = tracks, + $type = $type ?? 'playlist', + super._(); - factory _$WebSocketLoadEventDataImpl.fromJson(Map json) => - _$$WebSocketLoadEventDataImplFromJson(json); + factory _$WebSocketLoadEventDataPlaylistImpl.fromJson( + Map json) => + _$$WebSocketLoadEventDataPlaylistImplFromJson(json); final List _tracks; @override @@ -151,23 +223,26 @@ class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { } @override - final String? collectionId; + final PlaylistSimple? collection; @override final int? initialIndex; + @JsonKey(name: 'runtimeType') + final String $type; + @override String toString() { - return 'WebSocketLoadEventData(tracks: $tracks, collectionId: $collectionId, initialIndex: $initialIndex)'; + return 'WebSocketLoadEventData.playlist(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)'; } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$WebSocketLoadEventDataImpl && + other is _$WebSocketLoadEventDataPlaylistImpl && const DeepCollectionEquality().equals(other._tracks, _tracks) && - (identical(other.collectionId, collectionId) || - other.collectionId == collectionId) && + (identical(other.collection, collection) || + other.collection == collection) && (identical(other.initialIndex, initialIndex) || other.initialIndex == initialIndex)); } @@ -175,42 +250,361 @@ class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { @JsonKey(ignore: true) @override int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(_tracks), collectionId, initialIndex); + const DeepCollectionEquality().hash(_tracks), collection, initialIndex); @JsonKey(ignore: true) @override @pragma('vm:prefer-inline') - _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> - get copyWith => __$$WebSocketLoadEventDataImplCopyWithImpl< - _$WebSocketLoadEventDataImpl>(this, _$identity); + _$$WebSocketLoadEventDataPlaylistImplCopyWith< + _$WebSocketLoadEventDataPlaylistImpl> + get copyWith => __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl< + _$WebSocketLoadEventDataPlaylistImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) { + return playlist(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) { + return playlist?.call(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(tracks, collection, initialIndex); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, + required TResult Function(WebSocketLoadEventDataAlbum value) album, + }) { + return playlist(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult? Function(WebSocketLoadEventDataAlbum value)? album, + }) { + return playlist?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult Function(WebSocketLoadEventDataAlbum value)? album, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(this); + } + return orElse(); + } @override Map toJson() { - return _$$WebSocketLoadEventDataImplToJson( + return _$$WebSocketLoadEventDataPlaylistImplToJson( this, ); } } -abstract class _WebSocketLoadEventData implements WebSocketLoadEventData { - factory _WebSocketLoadEventData( +abstract class WebSocketLoadEventDataPlaylist extends WebSocketLoadEventData { + factory WebSocketLoadEventDataPlaylist( {@JsonKey(name: 'tracks', toJson: _tracksJson) required final List tracks, - final String? collectionId, - final int? initialIndex}) = _$WebSocketLoadEventDataImpl; + final PlaylistSimple? collection, + final int? initialIndex}) = _$WebSocketLoadEventDataPlaylistImpl; + WebSocketLoadEventDataPlaylist._() : super._(); - factory _WebSocketLoadEventData.fromJson(Map json) = - _$WebSocketLoadEventDataImpl.fromJson; + factory WebSocketLoadEventDataPlaylist.fromJson(Map json) = + _$WebSocketLoadEventDataPlaylistImpl.fromJson; @override @JsonKey(name: 'tracks', toJson: _tracksJson) List get tracks; @override - String? get collectionId; + PlaylistSimple? get collection; @override int? get initialIndex; @override @JsonKey(ignore: true) - _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + _$$WebSocketLoadEventDataPlaylistImplCopyWith< + _$WebSocketLoadEventDataPlaylistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res> + implements $WebSocketLoadEventDataCopyWith<$Res> { + factory _$$WebSocketLoadEventDataAlbumImplCopyWith( + _$WebSocketLoadEventDataAlbumImpl value, + $Res Function(_$WebSocketLoadEventDataAlbumImpl) then) = + __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex}); +} + +/// @nodoc +class __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res> + extends _$WebSocketLoadEventDataCopyWithImpl<$Res, + _$WebSocketLoadEventDataAlbumImpl> + implements _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res> { + __$$WebSocketLoadEventDataAlbumImplCopyWithImpl( + _$WebSocketLoadEventDataAlbumImpl _value, + $Res Function(_$WebSocketLoadEventDataAlbumImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tracks = null, + Object? collection = freezed, + Object? initialIndex = freezed, + }) { + return _then(_$WebSocketLoadEventDataAlbumImpl( + tracks: null == tracks + ? _value._tracks + : tracks // ignore: cast_nullable_to_non_nullable + as List, + collection: freezed == collection + ? _value.collection + : collection // ignore: cast_nullable_to_non_nullable + as AlbumSimple?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum { + _$WebSocketLoadEventDataAlbumImpl( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + this.collection, + this.initialIndex, + final String? $type}) + : _tracks = tracks, + $type = $type ?? 'album', + super._(); + + factory _$WebSocketLoadEventDataAlbumImpl.fromJson( + Map json) => + _$$WebSocketLoadEventDataAlbumImplFromJson(json); + + final List _tracks; + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks { + if (_tracks is EqualUnmodifiableListView) return _tracks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tracks); + } + + @override + final AlbumSimple? collection; + @override + final int? initialIndex; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'WebSocketLoadEventData.album(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$WebSocketLoadEventDataAlbumImpl && + const DeepCollectionEquality().equals(other._tracks, _tracks) && + (identical(other.collection, collection) || + other.collection == collection) && + (identical(other.initialIndex, initialIndex) || + other.initialIndex == initialIndex)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(_tracks), collection, initialIndex); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl> + get copyWith => __$$WebSocketLoadEventDataAlbumImplCopyWithImpl< + _$WebSocketLoadEventDataAlbumImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) { + return album(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) { + return album?.call(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + required TResult orElse(), + }) { + if (album != null) { + return album(tracks, collection, initialIndex); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, + required TResult Function(WebSocketLoadEventDataAlbum value) album, + }) { + return album(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult? Function(WebSocketLoadEventDataAlbum value)? album, + }) { + return album?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult Function(WebSocketLoadEventDataAlbum value)? album, + required TResult orElse(), + }) { + if (album != null) { + return album(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$WebSocketLoadEventDataAlbumImplToJson( + this, + ); + } +} + +abstract class WebSocketLoadEventDataAlbum extends WebSocketLoadEventData { + factory WebSocketLoadEventDataAlbum( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + final AlbumSimple? collection, + final int? initialIndex}) = _$WebSocketLoadEventDataAlbumImpl; + WebSocketLoadEventDataAlbum._() : super._(); + + factory WebSocketLoadEventDataAlbum.fromJson(Map json) = + _$WebSocketLoadEventDataAlbumImpl.fromJson; + + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks; + @override + AlbumSimple? get collection; + @override + int? get initialIndex; + @override + @JsonKey(ignore: true) + _$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/models/connect/connect.g.dart b/lib/models/connect/connect.g.dart index f636e035..f297024b 100644 --- a/lib/models/connect/connect.g.dart +++ b/lib/models/connect/connect.g.dart @@ -6,20 +6,48 @@ part of 'connect.dart'; // JsonSerializableGenerator // ************************************************************************** -_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson( - Map json) => - _$WebSocketLoadEventDataImpl( - tracks: (json['tracks'] as List) - .map((e) => Track.fromJson(e as Map)) - .toList(), - collectionId: json['collectionId'] as String?, - initialIndex: json['initialIndex'] as int?, - ); +_$WebSocketLoadEventDataPlaylistImpl + _$$WebSocketLoadEventDataPlaylistImplFromJson(Map json) => + _$WebSocketLoadEventDataPlaylistImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(Map.from(e as Map))) + .toList(), + collection: json['collection'] == null + ? null + : PlaylistSimple.fromJson( + Map.from(json['collection'] as Map)), + initialIndex: json['initialIndex'] as int?, + $type: json['runtimeType'] as String?, + ); -Map _$$WebSocketLoadEventDataImplToJson( - _$WebSocketLoadEventDataImpl instance) => +Map _$$WebSocketLoadEventDataPlaylistImplToJson( + _$WebSocketLoadEventDataPlaylistImpl instance) => { 'tracks': _tracksJson(instance.tracks), - 'collectionId': instance.collectionId, + 'collection': instance.collection?.toJson(), 'initialIndex': instance.initialIndex, + 'runtimeType': instance.$type, + }; + +_$WebSocketLoadEventDataAlbumImpl _$$WebSocketLoadEventDataAlbumImplFromJson( + Map json) => + _$WebSocketLoadEventDataAlbumImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(Map.from(e as Map))) + .toList(), + collection: json['collection'] == null + ? null + : AlbumSimple.fromJson( + Map.from(json['collection'] as Map)), + initialIndex: json['initialIndex'] as int?, + $type: json['runtimeType'] as String?, + ); + +Map _$$WebSocketLoadEventDataAlbumImplToJson( + _$WebSocketLoadEventDataAlbumImpl instance) => + { + 'tracks': _tracksJson(instance.tracks), + 'collection': instance.collection?.toJson(), + 'initialIndex': instance.initialIndex, + 'runtimeType': instance.$type, }; diff --git a/lib/models/connect/load.dart b/lib/models/connect/load.dart index d750cddd..bf0e164d 100644 --- a/lib/models/connect/load.dart +++ b/lib/models/connect/load.dart @@ -6,14 +6,27 @@ List> _tracksJson(List tracks) { @freezed class WebSocketLoadEventData with _$WebSocketLoadEventData { - factory WebSocketLoadEventData({ + const WebSocketLoadEventData._(); + + factory WebSocketLoadEventData.playlist({ @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, - String? collectionId, + PlaylistSimple? collection, int? initialIndex, - }) = _WebSocketLoadEventData; + }) = WebSocketLoadEventDataPlaylist; + + factory WebSocketLoadEventData.album({ + @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, + AlbumSimple? collection, + int? initialIndex, + }) = WebSocketLoadEventDataAlbum; factory WebSocketLoadEventData.fromJson(Map json) => _$WebSocketLoadEventDataFromJson(json); + + String? get collectionId => when( + playlist: (tracks, collection, _) => collection?.id, + album: (tracks, collection, _) => collection?.id, + ); } class WebSocketLoadEvent extends WebSocketEvent { diff --git a/lib/models/current_playlist.dart b/lib/models/current_playlist.dart index 53ea2799..7e55e393 100644 --- a/lib/models/current_playlist.dart +++ b/lib/models/current_playlist.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; class CurrentPlaylist { diff --git a/lib/models/local_track.dart b/lib/models/local_track.dart index 923f5f26..def3b64f 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -1,5 +1,4 @@ import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; class LocalTrack extends Track { final String path; @@ -35,9 +34,10 @@ class LocalTrack extends Track { ); } + @override Map toJson() { return { - ...TrackExtensions.trackToJson(this), + ...super.toJson(), 'path': path, }; } diff --git a/lib/models/source_match.g.dart b/lib/models/source_match.g.dart index 11f34bf3..3b469694 100644 --- a/lib/models/source_match.g.dart +++ b/lib/models/source_match.g.dart @@ -97,7 +97,7 @@ class SourceTypeAdapter extends TypeAdapter { // JsonSerializableGenerator // ************************************************************************** -SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( +SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( id: json['id'] as String, sourceId: json['sourceId'] as String, sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']), diff --git a/lib/models/spotify/home_feed.g.dart b/lib/models/spotify/home_feed.g.dart index 73a4f909..fceb3db4 100644 --- a/lib/models/spotify/home_feed.g.dart +++ b/lib/models/spotify/home_feed.g.dart @@ -6,14 +6,13 @@ part of 'home_feed.dart'; // JsonSerializableGenerator // ************************************************************************** -_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson( - Map json) => +_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson(Map json) => _$SpotifySectionPlaylistImpl( description: json['description'] as String, format: json['format'] as String, images: (json['images'] as List) - .map((e) => - SpotifySectionItemImage.fromJson(e as Map)) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) .toList(), name: json['name'] as String, owner: json['owner'] as String, @@ -25,20 +24,19 @@ Map _$$SpotifySectionPlaylistImplToJson( { 'description': instance.description, 'format': instance.format, - 'images': instance.images, + 'images': instance.images.map((e) => e.toJson()).toList(), 'name': instance.name, 'owner': instance.owner, 'uri': instance.uri, }; -_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson( - Map json) => +_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson(Map json) => _$SpotifySectionArtistImpl( name: json['name'] as String, uri: json['uri'] as String, images: (json['images'] as List) - .map((e) => - SpotifySectionItemImage.fromJson(e as Map)) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) .toList(), ); @@ -47,19 +45,18 @@ Map _$$SpotifySectionArtistImplToJson( { 'name': instance.name, 'uri': instance.uri, - 'images': instance.images, + 'images': instance.images.map((e) => e.toJson()).toList(), }; -_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson( - Map json) => +_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(Map json) => _$SpotifySectionAlbumImpl( artists: (json['artists'] as List) - .map((e) => - SpotifySectionAlbumArtist.fromJson(e as Map)) + .map((e) => SpotifySectionAlbumArtist.fromJson( + Map.from(e as Map))) .toList(), images: (json['images'] as List) - .map((e) => - SpotifySectionItemImage.fromJson(e as Map)) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) .toList(), name: json['name'] as String, uri: json['uri'] as String, @@ -68,14 +65,14 @@ _$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson( Map _$$SpotifySectionAlbumImplToJson( _$SpotifySectionAlbumImpl instance) => { - 'artists': instance.artists, - 'images': instance.images, + 'artists': instance.artists.map((e) => e.toJson()).toList(), + 'images': instance.images.map((e) => e.toJson()).toList(), 'name': instance.name, 'uri': instance.uri, }; _$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson( - Map json) => + Map json) => _$SpotifySectionAlbumArtistImpl( name: json['name'] as String, uri: json['uri'] as String, @@ -89,7 +86,7 @@ Map _$$SpotifySectionAlbumArtistImplToJson( }; _$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson( - Map json) => + Map json) => _$SpotifySectionItemImageImpl( height: json['height'] as num?, url: json['url'] as String, @@ -105,40 +102,40 @@ Map _$$SpotifySectionItemImageImplToJson( }; _$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson( - Map json) => + Map json) => _$SpotifyHomeFeedSectionItemImpl( typename: json['typename'] as String, playlist: json['playlist'] == null ? null : SpotifySectionPlaylist.fromJson( - json['playlist'] as Map), + Map.from(json['playlist'] as Map)), artist: json['artist'] == null ? null : SpotifySectionArtist.fromJson( - json['artist'] as Map), + Map.from(json['artist'] as Map)), album: json['album'] == null ? null - : SpotifySectionAlbum.fromJson(json['album'] as Map), + : SpotifySectionAlbum.fromJson( + Map.from(json['album'] as Map)), ); Map _$$SpotifyHomeFeedSectionItemImplToJson( _$SpotifyHomeFeedSectionItemImpl instance) => { 'typename': instance.typename, - 'playlist': instance.playlist, - 'artist': instance.artist, - 'album': instance.album, + 'playlist': instance.playlist?.toJson(), + 'artist': instance.artist?.toJson(), + 'album': instance.album?.toJson(), }; -_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson( - Map json) => +_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson(Map json) => _$SpotifyHomeFeedSectionImpl( typename: json['typename'] as String, title: json['title'] as String?, uri: json['uri'] as String, items: (json['items'] as List) - .map((e) => - SpotifyHomeFeedSectionItem.fromJson(e as Map)) + .map((e) => SpotifyHomeFeedSectionItem.fromJson( + Map.from(e as Map))) .toList(), ); @@ -148,16 +145,15 @@ Map _$$SpotifyHomeFeedSectionImplToJson( 'typename': instance.typename, 'title': instance.title, 'uri': instance.uri, - 'items': instance.items, + 'items': instance.items.map((e) => e.toJson()).toList(), }; -_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson( - Map json) => +_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson(Map json) => _$SpotifyHomeFeedImpl( greeting: json['greeting'] as String, sections: (json['sections'] as List) - .map( - (e) => SpotifyHomeFeedSection.fromJson(e as Map)) + .map((e) => SpotifyHomeFeedSection.fromJson( + Map.from(e as Map))) .toList(), ); @@ -165,5 +161,5 @@ Map _$$SpotifyHomeFeedImplToJson( _$SpotifyHomeFeedImpl instance) => { 'greeting': instance.greeting, - 'sections': instance.sections, + 'sections': instance.sections.map((e) => e.toJson()).toList(), }; diff --git a/lib/models/spotify/recommendation_seeds.g.dart b/lib/models/spotify/recommendation_seeds.g.dart index bdfa3a07..accb2ed1 100644 --- a/lib/models/spotify/recommendation_seeds.g.dart +++ b/lib/models/spotify/recommendation_seeds.g.dart @@ -6,8 +6,7 @@ part of 'recommendation_seeds.dart'; // JsonSerializableGenerator // ************************************************************************** -_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson( - Map json) => +_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson(Map json) => _$RecommendationSeedsImpl( acousticness: json['acousticness'] as num?, danceability: json['danceability'] as num?, diff --git a/lib/models/spotify_friends.g.dart b/lib/models/spotify_friends.g.dart index 4a32dd09..a1248429 100644 --- a/lib/models/spotify_friends.g.dart +++ b/lib/models/spotify_friends.g.dart @@ -6,60 +6,55 @@ part of 'spotify_friends.dart'; // JsonSerializableGenerator // ************************************************************************** -SpotifyFriend _$SpotifyFriendFromJson(Map json) => - SpotifyFriend( +SpotifyFriend _$SpotifyFriendFromJson(Map json) => SpotifyFriend( uri: json['uri'] as String, name: json['name'] as String, imageUrl: json['imageUrl'] as String, ); -SpotifyActivityArtist _$SpotifyActivityArtistFromJson( - Map json) => +SpotifyActivityArtist _$SpotifyActivityArtistFromJson(Map json) => SpotifyActivityArtist( uri: json['uri'] as String, name: json['name'] as String, ); -SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson( - Map json) => +SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson(Map json) => SpotifyActivityAlbum( uri: json['uri'] as String, name: json['name'] as String, ); -SpotifyActivityContext _$SpotifyActivityContextFromJson( - Map json) => +SpotifyActivityContext _$SpotifyActivityContextFromJson(Map json) => SpotifyActivityContext( uri: json['uri'] as String, name: json['name'] as String, index: json['index'] as num, ); -SpotifyActivityTrack _$SpotifyActivityTrackFromJson( - Map json) => +SpotifyActivityTrack _$SpotifyActivityTrackFromJson(Map json) => SpotifyActivityTrack( uri: json['uri'] as String, name: json['name'] as String, imageUrl: json['imageUrl'] as String, artist: SpotifyActivityArtist.fromJson( - json['artist'] as Map), - album: - SpotifyActivityAlbum.fromJson(json['album'] as Map), + Map.from(json['artist'] as Map)), + album: SpotifyActivityAlbum.fromJson( + Map.from(json['album'] as Map)), context: SpotifyActivityContext.fromJson( - json['context'] as Map), + Map.from(json['context'] as Map)), ); -SpotifyFriendActivity _$SpotifyFriendActivityFromJson( - Map json) => +SpotifyFriendActivity _$SpotifyFriendActivityFromJson(Map json) => SpotifyFriendActivity( - user: SpotifyFriend.fromJson(json['user'] as Map), - track: - SpotifyActivityTrack.fromJson(json['track'] as Map), + user: SpotifyFriend.fromJson( + Map.from(json['user'] as Map)), + track: SpotifyActivityTrack.fromJson( + Map.from(json['track'] as Map)), ); -SpotifyFriends _$SpotifyFriendsFromJson(Map json) => - SpotifyFriends( +SpotifyFriends _$SpotifyFriendsFromJson(Map json) => SpotifyFriends( friends: (json['friends'] as List) - .map((e) => SpotifyFriendActivity.fromJson(e as Map)) + .map((e) => SpotifyFriendActivity.fromJson( + Map.from(e as Map))) .toList(), ); diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index b24b69f4..aea890a0 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -8,6 +8,8 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class AlbumPage extends HookConsumerWidget { + static const name = "album"; + final AlbumSimple album; const AlbumPage({ super.key, @@ -22,7 +24,7 @@ class AlbumPage extends HookConsumerWidget { final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); return InheritedTrackView( - collectionId: album.id!, + collection: album, image: album.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index c3b04691..49890949 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -15,6 +15,8 @@ import 'package:spotube/pages/artist/section/top_tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPage extends HookConsumerWidget { + static const name = "artist"; + final String artistId; final logger = getLogger(ArtistPage); ArtistPage(this.artistId, {super.key}); diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 9d407899..595ac510 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -52,8 +52,9 @@ class ArtistPageTopTracks extends HookConsumerWidget { if (!isPlaylistPlaying) { await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.playlist( tracks: tracks, + collection: null, initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), ), ); diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index cbdb446e..c7cb493a 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -5,10 +5,13 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/connect/local_devices.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/utils/service_utils.dart'; class ConnectPage extends HookConsumerWidget { + static const name = "connect"; + const ConnectPage({super.key}); @override @@ -65,9 +68,9 @@ class ConnectPage extends HookConsumerWidget { selected: selected, onTap: () { if (selected) { - ServiceUtils.push( + ServiceUtils.pushNamed( context, - "/connect/control", + ConnectControlPage.name, ); } else { connectClientsNotifier.resolveService(device); diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index b78f0ed3..639a9dd9 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -13,6 +13,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; @@ -46,6 +47,8 @@ class RemotePlayerQueue extends ConsumerWidget { } class ConnectControlPage extends HookConsumerWidget { + static const name = "connect_control"; + const ConnectControlPage({super.key}); @override @@ -125,9 +128,13 @@ class ConnectControlPage extends HookConsumerWidget { playlist.activeTrack?.name ?? "", style: textTheme.titleLarge!, onTap: () { - ServiceUtils.push( + if (playlist.activeTrack == null) return; + ServiceUtils.pushNamed( context, - "/track/${playlist.activeTrack?.id}", + TrackPage.name, + pathParameters: { + "id": playlist.activeTrack!.id!, + }, ); }, ), diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index 9c061091..9c9bdddb 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -7,8 +7,10 @@ import 'package:spotube/components/desktop_login/login_form.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/pages/mobile_login/mobile_login.dart'; class DesktopLoginPage extends HookConsumerWidget { + static const name = WebViewLogin.name; const DesktopLoginPage({super.key}); @override diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index 83b04af1..dbec28dc 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -8,10 +8,12 @@ import 'package:spotube/components/desktop_login/login_form.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class LoginTutorial extends ConsumerWidget { + static const name = "login_tutorial"; const LoginTutorial({super.key}); @override @@ -53,7 +55,7 @@ class LoginTutorial extends ConsumerWidget { overrideDone: FilledButton( onPressed: authenticationNotifier.isLoggedIn ? () { - ServiceUtils.push(context, "/"); + ServiceUtils.pushNamed(context, HomePage.name); } : null, child: Center(child: Text(context.l10n.done)), diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart index cbab03b9..fa205403 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -12,6 +12,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/themes/theme.dart'; class GettingStarting extends HookConsumerWidget { + static const name = "getting_started"; + const GettingStarting({super.key}); @override diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index 46823425..7bccfe06 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -5,6 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -104,7 +106,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.go("/"); + context.go(HomePage.name); } }, ), @@ -120,7 +122,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.push("/login"); + context.pushNamed(WebViewLogin.name); } }, ), diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart index c945251c..d31b8256 100644 --- a/lib/pages/home/feed/feed_section.dart +++ b/lib/pages/home/feed/feed_section.dart @@ -10,6 +10,8 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/views/home_section.dart'; class HomeFeedSectionPage extends HookConsumerWidget { + static const name = "home_feed_section"; + final String sectionUri; const HomeFeedSectionPage({super.key, required this.sectionUri}); diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index ca4e7238..531ea889 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -15,6 +15,8 @@ import 'package:collection/collection.dart'; import 'package:spotube/utils/platform.dart'; class GenrePlaylistsPage extends HookConsumerWidget { + static const name = "genre_playlists"; + final Category category; const GenrePlaylistsPage({super.key, required this.category}); diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index 291ce737..bb84fc16 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -9,9 +9,11 @@ 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/pages/home/genres/genre_playlists.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class GenrePage extends HookConsumerWidget { + static const name = "genre"; const GenrePage({super.key}); @override @@ -47,7 +49,13 @@ class GenrePage extends HookConsumerWidget { return InkWell( borderRadius: BorderRadius.circular(8), onTap: () { - context.push("/genre/${category.id}", extra: category); + context.pushNamed( + GenrePlaylistsPage.name, + pathParameters: { + "categoryId": category.id!, + }, + extra: category, + ); }, child: Ink( padding: const EdgeInsets.all(8), diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index a4a71146..d4e2d94e 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/feed.dart'; @@ -10,16 +11,15 @@ import 'package:spotube/components/home/sections/friends.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/image/universal_image.dart'; +import 'package:spotube/components/home/sections/recent.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class HomePage extends HookConsumerWidget { + static const name = "home"; const HomePage({super.key}); @override @@ -34,44 +34,27 @@ class HomePage extends HookConsumerWidget { body: CustomScrollView( controller: controller, slivers: [ - if (mediaQuery.mdAndDown) + if (mediaQuery.smAndDown) SliverAppBar( floating: true, title: Assets.spotubeLogoPng.image(height: 45), actions: [ const ConnectDeviceButton(), const Gap(10), - Consumer(builder: (context, ref, _) { - final auth = ref.watch(authenticationProvider); - final me = ref.watch(meProvider); - final meData = me.asData?.value; - - if (auth == null) { - return const SizedBox(); - } - - return IconButton( - icon: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - (meData?.images).asUrlString( - placeholder: ImagePlaceholder.artist, - ), - ), - ), - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - ), - onPressed: () { - ServiceUtils.push(context, "/profile"); - }, - ); - }), + IconButton( + icon: const Icon(SpotubeIcons.settings, size: 20), + onPressed: () { + ServiceUtils.pushNamed(context, SettingsPage.name); + }, + ), const Gap(10), ], ) else if (kIsMacOS) const SliverGap(10), const HomeGenresSection(), + const SliverGap(10), + const SliverToBoxAdapter(child: HomeRecentlyPlayedSection()), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), const SliverToBoxAdapter(child: HomeNewReleasesSection()), diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index b6aeef2e..2baeaad9 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -10,6 +10,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; class LastFMLoginPage extends HookConsumerWidget { + static const name = "lastfm_login"; const LastFMLoginPage({super.key}); @override diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index eff30348..5385f872 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -12,6 +12,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class LibraryPage extends HookConsumerWidget { + static const name = "library"; + const LibraryPage({super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart index 6552bb5b..ac38e860 100644 --- a/lib/pages/library/local_folder.dart +++ b/lib/pages/library/local_folder.dart @@ -21,6 +21,8 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class LocalLibraryPage extends HookConsumerWidget { + static const name = "local_library_page"; + final String location; final bool isDownloads; const LocalLibraryPage(this.location, {super.key, this.isDownloads = false}); diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 5044090d..648e8528 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -24,6 +24,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); class PlaylistGeneratorPage extends HookConsumerWidget { + static const name = "playlist_generator"; + const PlaylistGeneratorPage({super.key}); @override diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 01b73267..5ee7ab36 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -10,10 +10,13 @@ import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistGenerateResultPage extends HookConsumerWidget { + static const name = "playlist_generate_result"; + final GeneratePlaylistProviderInput state; const PlaylistGenerateResultPage({ @@ -123,8 +126,11 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ); if (playlist != null) { - router.go( - '/playlist/${playlist.id}', + router.goNamed( + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, extra: playlist, ); } diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index ca13864a..850eccfa 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -23,6 +23,8 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class LyricsPage extends HookConsumerWidget { + static const name = "lyrics"; + final bool isModal; const LyricsPage({super.key, this.isModal = false}); diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 6d6f75a9..996e190d 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -20,6 +20,8 @@ import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; class MiniLyricsPage extends HookConsumerWidget { + static const name = "mini_lyrics"; + final Size prevSize; const MiniLyricsPage({super.key, required this.prevSize}); diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 0a1ff8b3..1f2df95a 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -7,6 +7,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/platform.dart'; class WebViewLogin extends HookConsumerWidget { + static const name = "login"; const WebViewLogin({super.key}); @override diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 72983518..44e99aea 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -3,9 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class LikedPlaylistPage extends HookConsumerWidget { + static const name = PlaylistPage.name; + final PlaylistSimple playlist; const LikedPlaylistPage({ super.key, @@ -18,7 +21,7 @@ class LikedPlaylistPage extends HookConsumerWidget { final tracks = likedTracks.asData?.value ?? []; return InheritedTrackView( - collectionId: playlist.id!, + collection: playlist, image: "assets/liked-tracks.jpg", pagination: PaginationProps( hasNextPage: false, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index d9d224e0..8fb22458 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -10,6 +10,8 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistPage extends HookConsumerWidget { + static const name = "playlist"; + final PlaylistSimple playlist; const PlaylistPage({ super.key, @@ -29,7 +31,7 @@ class PlaylistPage extends HookConsumerWidget { final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); return InheritedTrackView( - collectionId: playlist.id!, + collection: playlist, image: playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart index 52b69835..d77ae98d 100644 --- a/lib/pages/profile/profile.dart +++ b/lib/pages/profile/profile.dart @@ -14,6 +14,8 @@ import 'package:spotube/provider/spotify/spotify.dart'; import 'package:url_launcher/url_launcher_string.dart'; class ProfilePage extends HookConsumerWidget { + static const name = "profile"; + const ProfilePage({super.key}); @override diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 42bf3f69..258ecf3c 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; +import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -22,13 +23,6 @@ import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; -const rootPaths = { - "/": 0, - "/search": 1, - "/library": 2, - "/lyrics": 3, -}; - class RootApp extends HookConsumerWidget { final Widget child; const RootApp({ @@ -42,7 +36,6 @@ class RootApp extends HookConsumerWidget { final downloader = ref.watch(downloadManagerProvider); final scaffoldMessenger = ScaffoldMessenger.of(context); final theme = Theme.of(context); - final location = GoRouterState.of(context).matchedLocation; useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -179,32 +172,18 @@ class RootApp extends HookConsumerWidget { return null; }, [backgroundColor]); - void onSelectIndexChanged(int d) { - final invertedRouteMap = - rootPaths.map((key, value) => MapEntry(value, key)); - - if (context.mounted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - GoRouter.of(context).go(invertedRouteMap[d]!); - }); - } - } - // ignore: deprecated_member_use return WillPopScope( onWillPop: () async { - if (rootPaths[location] != 0) { - onSelectIndexChanged(0); + final routerState = GoRouterState.of(context); + if (routerState.matchedLocation != "/") { + context.goNamed(HomePage.name); return false; } return true; }, child: Scaffold( - body: Sidebar( - selectedIndex: rootPaths[location], - onSelectedIndexChanged: onSelectIndexChanged, - child: child, - ), + body: Sidebar(child: child), extendBody: true, drawerScrimColor: Colors.transparent, endDrawer: kIsDesktop @@ -238,10 +217,7 @@ class RootApp extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ BottomPlayer(), - SpotubeNavigationBar( - selectedIndex: rootPaths[location], - onSelectedIndexChanged: onSelectIndexChanged, - ), + const SpotubeNavigationBar(), ], ), ), diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index e9ada236..d5374786 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -26,6 +27,8 @@ import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; class SearchPage extends HookConsumerWidget { + static const name = "search"; + const SearchPage({super.key}); @override @@ -85,99 +88,117 @@ class SearchPage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: kIsDesktop && !kIsMacOS ? const PageWindowTitleBar() : null, + appBar: kIsDesktop && !kIsMacOS + ? const PageWindowTitleBar(automaticallyImplyLeading: true) + : null, body: !authenticationNotifier.isLoggedIn ? const AnonymousFallback() : Column( children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ), - color: theme.scaffoldBackgroundColor, - child: SearchAnchor( - searchController: controller, - viewBuilder: (_) => HookBuilder(builder: (context) { - final searchController = useListenable(controller); - final update = useForceUpdate(); - final suggestions = searchController.text.isEmpty - ? KVStoreService.recentSearches - : KVStoreService.recentSearches - .where( - (s) => - weightedRatio( - s.toLowerCase(), - searchController.text.toLowerCase(), - ) > - 50, - ) - .toList(); + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if ((kIsMobile || kIsMacOS) && context.canPop()) + const BackButton() + else + const Gap(20), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + right: 20, + top: 20, + bottom: 20, + ), + child: SearchAnchor( + searchController: controller, + viewBuilder: (_) => HookBuilder(builder: (context) { + final searchController = + useListenable(controller); + final update = useForceUpdate(); + final suggestions = searchController.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + searchController.text + .toLowerCase(), + ) > + 50, + ) + .toList(); - return ListView.builder( - itemCount: suggestions.length, - itemBuilder: (context, index) { - final suggestion = suggestions[index]; + return ListView.builder( + itemCount: suggestions.length, + itemBuilder: (context, index) { + final suggestion = suggestions[index]; - return ListTile( - leading: const Icon(SpotubeIcons.history), - title: Text(suggestion), - trailing: IconButton( - icon: const Icon(SpotubeIcons.trash), - onPressed: () { - KVStoreService.setRecentSearches( - KVStoreService.recentSearches - .where((s) => s != suggestion) - .toList(), + return ListTile( + leading: const Icon(SpotubeIcons.history), + title: Text(suggestion), + trailing: IconButton( + icon: const Icon(SpotubeIcons.trash), + onPressed: () { + KVStoreService.setRecentSearches( + KVStoreService.recentSearches + .where((s) => s != suggestion) + .toList(), + ); + update(); + }, + ), + onTap: () { + controller.closeView(suggestion); + ref + .read( + searchTermStateProvider.notifier) + .state = suggestion; + }, ); - update(); }, - ), - onTap: () { - controller.closeView(suggestion); - ref - .read(searchTermStateProvider.notifier) - .state = suggestion; - }, - ); - }, - ); - }), - suggestionsBuilder: (context, controller) { - return []; - }, - viewOnSubmitted: (value) async { - controller.closeView(value); - Timer( - const Duration(milliseconds: 50), - () { - ref.read(searchTermStateProvider.notifier).state = - value; - if (value.trim().isEmpty) { - return; - } - KVStoreService.setRecentSearches( - { - value, - ...KVStoreService.recentSearches, - }.toList(), - ); - }, - ); - }, - builder: (context, controller) { - return SearchBar( - autoFocus: queries.none((s) => - s.asData?.value != null && !s.hasError) && - !kIsMobile, - controller: controller, - leading: const Icon(SpotubeIcons.search), - hintText: "${context.l10n.search}...", - onTap: controller.openView, - onChanged: (_) => controller.openView(), - ); - }, - ), + ); + }), + suggestionsBuilder: (context, controller) { + return []; + }, + viewOnSubmitted: (value) async { + controller.closeView(value); + Timer( + const Duration(milliseconds: 50), + () { + ref + .read(searchTermStateProvider.notifier) + .state = value; + if (value.trim().isEmpty) { + return; + } + KVStoreService.setRecentSearches( + { + value, + ...KVStoreService.recentSearches, + }.toList(), + ); + }, + ); + }, + builder: (context, controller) { + return SearchBar( + autoFocus: queries.none((s) => + s.asData?.value != null && + !s.hasError) && + !kIsMobile, + controller: controller, + leading: const Icon(SpotubeIcons.search), + hintText: "${context.l10n.search}...", + onTap: controller.openView, + onChanged: (_) => controller.openView(), + ); + }, + ), + ), + ), + ], ), Expanded( child: AnimatedSwitcher( diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 7fb58759..bd7f3c88 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -76,7 +76,7 @@ class SearchTracksSection extends HookConsumerWidget { if (shouldPlay) { await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.playlist( tracks: [track], ), ); diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 505eecb9..e7d95759 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -17,6 +17,8 @@ final _licenseProvider = FutureProvider((ref) async { }); class AboutSpotube extends HookConsumerWidget { + static const name = "about"; + const AboutSpotube({super.key}); @override diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 9dd85c50..6eccab07 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -11,6 +11,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; class BlackListPage extends HookConsumerWidget { + static const name = "blacklist"; + const BlackListPage({super.key}); @override diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index b07ebbb1..8b6f7312 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -11,6 +11,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; class LogsPage extends HookWidget { + static const name = "logs"; + const LogsPage({super.key}); List<({DateTime? date, String body})> parseLogs(String raw) { diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index ab3a7c92..6162aa3d 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -4,10 +4,15 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/utils/service_utils.dart'; class SettingsAccountSection extends HookConsumerWidget { const SettingsAccountSection({super.key}); @@ -15,9 +20,12 @@ class SettingsAccountSection extends HookConsumerWidget { @override Widget build(context, ref) { final theme = Theme.of(context); + final router = GoRouter.of(context); + final auth = ref.watch(authenticationProvider); final scrobbler = ref.watch(scrobblerProvider); - final router = GoRouter.of(context); + final me = ref.watch(meProvider); + final meData = me.asData?.value; final logoutBtnStyle = FilledButton.styleFrom( backgroundColor: Colors.red, @@ -27,6 +35,24 @@ class SettingsAccountSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.account, children: [ + if (auth != null) + ListTile( + leading: const Icon(SpotubeIcons.user), + title: const Text("User Profile"), + trailing: Padding( + padding: const EdgeInsets.all(8.0), + child: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (meData?.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + ), + onTap: () { + ServiceUtils.pushNamed(context, ProfilePage.name); + }, + ), if (auth == null) LayoutBuilder(builder: (context, constrains) { return ListTile( diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index d293518d..af0fc095 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -16,6 +16,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/utils/platform.dart'; class SettingsPage extends HookConsumerWidget { + static const name = "settings"; + const SettingsPage({super.key}); @override @@ -29,6 +31,7 @@ class SettingsPage extends HookConsumerWidget { appBar: PageWindowTitleBar( title: Text(context.l10n.settings), centerTitle: true, + automaticallyImplyLeading: true, ), body: Scrollbar( controller: controller, diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart new file mode 100644 index 00000000..83867f93 --- /dev/null +++ b/lib/pages/stats/albums/albums.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/album_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsAlbumsPage extends HookConsumerWidget { + static const name = "stats_albums"; + const StatsAlbumsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final albums = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.albums), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Albums"), + ), + body: ListView.builder( + itemCount: albums.length, + itemBuilder: (context, index) { + final album = albums[index]; + return StatsAlbumItem( + album: album.album, + info: Text("${compactNumberFormatter.format(album.count)} plays"), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart new file mode 100644 index 00000000..755475ae --- /dev/null +++ b/lib/pages/stats/artists/artists.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsArtistsPage extends HookConsumerWidget { + static const name = "stats_artists"; + const StatsArtistsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final artists = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.artists), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Artists"), + ), + body: ListView.builder( + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text("${compactNumberFormatter.format(artist.count)} plays"), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart new file mode 100644 index 00000000..228d3243 --- /dev/null +++ b/lib/pages/stats/fees/fees.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsStreamFeesPage extends HookConsumerWidget { + static const name = "stats_stream_fees"; + + const StatsStreamFeesPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :hintColor) = Theme.of(context); + + final artists = ref.watch( + playbackHistoryTopProvider(HistoryDuration.days30) + .select((value) => value.artists), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Streaming fees (hypothetical)"), + ), + body: CustomScrollView( + slivers: [ + SliverCrossAxisConstrained( + maxCrossAxisExtent: 600, + alignment: -1, + child: SliverPadding( + padding: const EdgeInsets.all(16.0), + sliver: SliverToBoxAdapter( + child: Text( + "*This is calculated based on Spotify's per stream " + "payout of \$0.003 to \$0.005. This is a hypothetical " + "calculation to give user insight about how much they " + "would have paid to the artists if they were to listen " + "their song in Spotify.", + style: textTheme.bodySmall?.copyWith( + color: hintColor, + ), + ), + ), + ), + ), + SliverList.builder( + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text(usdFormatter.format(artist.count * 0.005)), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart new file mode 100644 index 00000000..b22f9a4f --- /dev/null +++ b/lib/pages/stats/minutes/minutes.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsMinutesPage extends HookConsumerWidget { + static const name = "stats_minutes"; + + const StatsMinutesPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final topTracks = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.tracks), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + title: Text("Minutes listened"), + centerTitle: false, + automaticallyImplyLeading: true, + ), + body: ListView.separated( + separatorBuilder: (context, index) => const Gap(8), + itemCount: topTracks.length, + itemBuilder: (context, index) { + final (:track, :count) = topTracks[index]; + + return StatsTrackItem( + track: track, + info: Text( + "${compactNumberFormatter.format(count * track.duration!.inMinutes)} mins", + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart new file mode 100644 index 00000000..cca7febb --- /dev/null +++ b/lib/pages/stats/playlists/playlists.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/playlist_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsPlaylistsPage extends HookConsumerWidget { + static const name = "stats_playlists"; + const StatsPlaylistsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final playlists = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.playlists), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Playlists"), + ), + body: ListView.builder( + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + return StatsPlaylistItem( + playlist: playlist.playlist.playlist, + info: + Text("${compactNumberFormatter.format(playlist.count)} plays"), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart new file mode 100644 index 00000000..95493591 --- /dev/null +++ b/lib/pages/stats/stats.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/summary/summary.dart'; +import 'package:spotube/components/stats/top/top.dart'; +import 'package:spotube/utils/platform.dart'; + +class StatsPage extends HookConsumerWidget { + static const name = "stats"; + + const StatsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + return SafeArea( + bottom: false, + child: Scaffold( + appBar: kIsMacOS || kIsMobile ? null : const PageWindowTitleBar(), + body: CustomScrollView( + slivers: [ + if (kIsMacOS) const SliverGap(20), + const StatsPageSummarySection(), + const StatsPageTopSection(), + const SliverToBoxAdapter( + child: SafeArea( + child: SizedBox(), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart new file mode 100644 index 00000000..33480709 --- /dev/null +++ b/lib/pages/stats/streams/streams.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsStreamsPage extends HookConsumerWidget { + static const name = "stats_streams"; + + const StatsStreamsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final topTracks = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.tracks), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + title: Text("Streamed songs"), + centerTitle: false, + automaticallyImplyLeading: true, + ), + body: ListView.separated( + separatorBuilder: (context, index) => const Gap(8), + itemCount: topTracks.length, + itemBuilder: (context, index) { + final (:track, :count) = topTracks[index]; + + return StatsTrackItem( + track: track, + info: Text( + "${compactNumberFormatter.format(count)} streams", + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index fc90d19a..2109fe6e 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -21,6 +21,8 @@ import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/extensions/constrains.dart'; class TrackPage extends HookConsumerWidget { + static const name = "track"; + final String trackId; const TrackPage({ super.key, diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart index ebf53e43..9c4e6466 100644 --- a/lib/provider/connect/server.dart +++ b/lib/provider/connect/server.dart @@ -9,9 +9,11 @@ import 'package:shelf/shelf_io.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -32,6 +34,7 @@ final connectServerProvider = FutureProvider((ref) async { final resolvedService = await ref .watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); final playbackNotifier = ref.read(proxyPlaylistProvider.notifier); + final historyNotifier = ref.read(playbackHistoryProvider.notifier); if (!enabled || resolvedService != null) { return null; @@ -79,7 +82,7 @@ final connectServerProvider = FutureProvider((ref) async { .toJson(), ); channel.sink.add( - WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(), + WebSocketShuffleEvent(audioPlayer.isShuffled).toJson(), ); channel.sink.add( WebSocketLoopEvent(audioPlayer.loopMode).toJson(), @@ -146,8 +149,14 @@ final connectServerProvider = FutureProvider((ref) async { initialIndex: event.data.initialIndex ?? 0, ); - if (event.data.collectionId != null) { - playbackNotifier.addCollection(event.data.collectionId!); + if (event.data.collectionId == null) return; + playbackNotifier.addCollection(event.data.collectionId!); + if (event.data.collection is AlbumSimple) { + historyNotifier + .addAlbums([event.data.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists( + [event.data.collection as PlaylistSimple]); } }); diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart new file mode 100644 index 00000000..4436626d --- /dev/null +++ b/lib/provider/history/history.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/utils/persisted_state_notifier.dart'; + +class PlaybackHistoryState { + final List items; + const PlaybackHistoryState({this.items = const []}); + + factory PlaybackHistoryState.fromJson(Map json) { + return PlaybackHistoryState( + items: json["items"] + ?.map( + (json) => PlaybackHistoryItem.fromJson(json), + ) + .toList() + .cast() ?? + [], + ); + } + + Map toJson() { + return { + "items": items.map((s) => s.toJson()).toList(), + }; + } + + PlaybackHistoryState copyWith({ + List? items, + }) { + return PlaybackHistoryState(items: items ?? this.items); + } +} + +class PlaybackHistoryNotifier + extends PersistedStateNotifier { + final Ref ref; + PlaybackHistoryNotifier(this.ref) + : super(const PlaybackHistoryState(), "playback_history"); + + SpotifyApi get spotify => ref.read(spotifyProvider); + + @override + FutureOr fromJson(Map json) => + PlaybackHistoryState.fromJson(json); + + @override + Map toJson() { + return state.toJson(); + } + + void addPlaylists(List playlists) { + state = state.copyWith( + items: [ + ...state.items, + for (final playlist in playlists) + PlaybackHistoryItem.playlist( + date: DateTime.now(), playlist: playlist), + ], + ); + } + + void addAlbums(List albums) { + state = state.copyWith( + items: [ + ...state.items, + for (final album in albums) + PlaybackHistoryItem.album(date: DateTime.now(), album: album), + ], + ); + } + + void addTrack(Track track) async { + // For some reason Track's artists images are `null` + // so we need to fetch them from the API + final artists = + await spotify.artists.list(track.artists!.map((e) => e.id!).toList()); + + track.artists = artists.toList(); + + state = state.copyWith( + items: [ + ...state.items, + PlaybackHistoryItem.track(date: DateTime.now(), track: track), + ], + ); + } + + void clear() { + state = state.copyWith(items: []); + } +} + +final playbackHistoryProvider = + StateNotifierProvider( + (ref) => PlaybackHistoryNotifier(ref), +); + +typedef PlaybackHistoryGrouped = ({ + List tracks, + List albums, + List playlists, +}); + +final playbackHistoryGroupedProvider = Provider((ref) { + final history = ref.watch(playbackHistoryProvider); + final tracks = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + final albums = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + final playlists = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + + return ( + tracks: tracks, + albums: albums, + playlists: playlists, + ); +}); diff --git a/lib/provider/history/recent.dart b/lib/provider/history/recent.dart new file mode 100644 index 00000000..9953858d --- /dev/null +++ b/lib/provider/history/recent.dart @@ -0,0 +1,40 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/history/state.dart'; + +final recentlyPlayedItems = Provider((ref) { + return ref.watch( + playbackHistoryProvider.select( + (s) => s.items + .toSet() + // unique items + .whereIndexed( + (index, item) => + index == + s.items.lastIndexWhere( + (e) => switch ((e, item)) { + ( + PlaybackHistoryPlaylist(:final playlist), + PlaybackHistoryPlaylist(playlist: final playlist2) + ) => + playlist.id == playlist2.id, + ( + PlaybackHistoryAlbum(:final album), + PlaybackHistoryAlbum(album: final album2) + ) => + album.id == album2.id, + _ => false, + }, + ), + ) + .where( + (s) => s is PlaybackHistoryPlaylist || s is PlaybackHistoryAlbum, + ) + .take(10) + .sortedBy((s) => s.date) + .reversed + .toList(), + ), + ); +}); diff --git a/lib/provider/history/state.dart b/lib/provider/history/state.dart new file mode 100644 index 00000000..67658502 --- /dev/null +++ b/lib/provider/history/state.dart @@ -0,0 +1,35 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; + +part 'state.freezed.dart'; +part 'state.g.dart'; + +enum HistoryDuration { + allTime, + days7, + days30, + months6, + year, + years2, +} + +@freezed +class PlaybackHistoryItem with _$PlaybackHistoryItem { + factory PlaybackHistoryItem.playlist({ + required DateTime date, + required PlaylistSimple playlist, + }) = PlaybackHistoryPlaylist; + + factory PlaybackHistoryItem.album({ + required DateTime date, + required AlbumSimple album, + }) = PlaybackHistoryAlbum; + + factory PlaybackHistoryItem.track({ + required DateTime date, + required Track track, + }) = PlaybackHistoryTrack; + + factory PlaybackHistoryItem.fromJson(Map json) => + _$PlaybackHistoryItemFromJson(json); +} diff --git a/lib/provider/history/state.freezed.dart b/lib/provider/history/state.freezed.dart new file mode 100644 index 00000000..e2ee9421 --- /dev/null +++ b/lib/provider/history/state.freezed.dart @@ -0,0 +1,644 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +PlaybackHistoryItem _$PlaybackHistoryItemFromJson(Map json) { + switch (json['runtimeType']) { + case 'playlist': + return PlaybackHistoryPlaylist.fromJson(json); + case 'album': + return PlaybackHistoryAlbum.fromJson(json); + case 'track': + return PlaybackHistoryTrack.fromJson(json); + + default: + throw CheckedFromJsonException(json, 'runtimeType', 'PlaybackHistoryItem', + 'Invalid union type "${json['runtimeType']}"!'); + } +} + +/// @nodoc +mixin _$PlaybackHistoryItem { + DateTime get date => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $PlaybackHistoryItemCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PlaybackHistoryItemCopyWith<$Res> { + factory $PlaybackHistoryItemCopyWith( + PlaybackHistoryItem value, $Res Function(PlaybackHistoryItem) then) = + _$PlaybackHistoryItemCopyWithImpl<$Res, PlaybackHistoryItem>; + @useResult + $Res call({DateTime date}); +} + +/// @nodoc +class _$PlaybackHistoryItemCopyWithImpl<$Res, $Val extends PlaybackHistoryItem> + implements $PlaybackHistoryItemCopyWith<$Res> { + _$PlaybackHistoryItemCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + }) { + return _then(_value.copyWith( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$PlaybackHistoryPlaylistImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryPlaylistImplCopyWith( + _$PlaybackHistoryPlaylistImpl value, + $Res Function(_$PlaybackHistoryPlaylistImpl) then) = + __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, PlaylistSimple playlist}); +} + +/// @nodoc +class __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, + _$PlaybackHistoryPlaylistImpl> + implements _$$PlaybackHistoryPlaylistImplCopyWith<$Res> { + __$$PlaybackHistoryPlaylistImplCopyWithImpl( + _$PlaybackHistoryPlaylistImpl _value, + $Res Function(_$PlaybackHistoryPlaylistImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? playlist = null, + }) { + return _then(_$PlaybackHistoryPlaylistImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + playlist: null == playlist + ? _value.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as PlaylistSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist { + _$PlaybackHistoryPlaylistImpl( + {required this.date, required this.playlist, final String? $type}) + : $type = $type ?? 'playlist'; + + factory _$PlaybackHistoryPlaylistImpl.fromJson(Map json) => + _$$PlaybackHistoryPlaylistImplFromJson(json); + + @override + final DateTime date; + @override + final PlaylistSimple playlist; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.playlist(date: $date, playlist: $playlist)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryPlaylistImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.playlist, playlist) || + other.playlist == playlist)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, playlist); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> + get copyWith => __$$PlaybackHistoryPlaylistImplCopyWithImpl< + _$PlaybackHistoryPlaylistImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return playlist(date, this.playlist); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return playlist?.call(date, this.playlist); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(date, this.playlist); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return playlist(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return playlist?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryPlaylistImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryPlaylist implements PlaybackHistoryItem { + factory PlaybackHistoryPlaylist( + {required final DateTime date, + required final PlaylistSimple playlist}) = _$PlaybackHistoryPlaylistImpl; + + factory PlaybackHistoryPlaylist.fromJson(Map json) = + _$PlaybackHistoryPlaylistImpl.fromJson; + + @override + DateTime get date; + PlaylistSimple get playlist; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PlaybackHistoryAlbumImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryAlbumImplCopyWith(_$PlaybackHistoryAlbumImpl value, + $Res Function(_$PlaybackHistoryAlbumImpl) then) = + __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, AlbumSimple album}); +} + +/// @nodoc +class __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryAlbumImpl> + implements _$$PlaybackHistoryAlbumImplCopyWith<$Res> { + __$$PlaybackHistoryAlbumImplCopyWithImpl(_$PlaybackHistoryAlbumImpl _value, + $Res Function(_$PlaybackHistoryAlbumImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? album = null, + }) { + return _then(_$PlaybackHistoryAlbumImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + album: null == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as AlbumSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum { + _$PlaybackHistoryAlbumImpl( + {required this.date, required this.album, final String? $type}) + : $type = $type ?? 'album'; + + factory _$PlaybackHistoryAlbumImpl.fromJson(Map json) => + _$$PlaybackHistoryAlbumImplFromJson(json); + + @override + final DateTime date; + @override + final AlbumSimple album; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.album(date: $date, album: $album)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryAlbumImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.album, album) || other.album == album)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, album); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> + get copyWith => + __$$PlaybackHistoryAlbumImplCopyWithImpl<_$PlaybackHistoryAlbumImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return album(date, this.album); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return album?.call(date, this.album); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (album != null) { + return album(date, this.album); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return album(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return album?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (album != null) { + return album(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryAlbumImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryAlbum implements PlaybackHistoryItem { + factory PlaybackHistoryAlbum( + {required final DateTime date, + required final AlbumSimple album}) = _$PlaybackHistoryAlbumImpl; + + factory PlaybackHistoryAlbum.fromJson(Map json) = + _$PlaybackHistoryAlbumImpl.fromJson; + + @override + DateTime get date; + AlbumSimple get album; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PlaybackHistoryTrackImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryTrackImplCopyWith(_$PlaybackHistoryTrackImpl value, + $Res Function(_$PlaybackHistoryTrackImpl) then) = + __$$PlaybackHistoryTrackImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, Track track}); +} + +/// @nodoc +class __$$PlaybackHistoryTrackImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryTrackImpl> + implements _$$PlaybackHistoryTrackImplCopyWith<$Res> { + __$$PlaybackHistoryTrackImplCopyWithImpl(_$PlaybackHistoryTrackImpl _value, + $Res Function(_$PlaybackHistoryTrackImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? track = null, + }) { + return _then(_$PlaybackHistoryTrackImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + track: null == track + ? _value.track + : track // ignore: cast_nullable_to_non_nullable + as Track, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { + _$PlaybackHistoryTrackImpl( + {required this.date, required this.track, final String? $type}) + : $type = $type ?? 'track'; + + factory _$PlaybackHistoryTrackImpl.fromJson(Map json) => + _$$PlaybackHistoryTrackImplFromJson(json); + + @override + final DateTime date; + @override + final Track track; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.track(date: $date, track: $track)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryTrackImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.track, track) || other.track == track)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, track); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> + get copyWith => + __$$PlaybackHistoryTrackImplCopyWithImpl<_$PlaybackHistoryTrackImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return track(date, this.track); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return track?.call(date, this.track); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (track != null) { + return track(date, this.track); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return track(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return track?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (track != null) { + return track(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryTrackImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryTrack implements PlaybackHistoryItem { + factory PlaybackHistoryTrack( + {required final DateTime date, + required final Track track}) = _$PlaybackHistoryTrackImpl; + + factory PlaybackHistoryTrack.fromJson(Map json) = + _$PlaybackHistoryTrackImpl.fromJson; + + @override + DateTime get date; + Track get track; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/provider/history/state.g.dart b/lib/provider/history/state.g.dart new file mode 100644 index 00000000..dfd01c2c --- /dev/null +++ b/lib/provider/history/state.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$PlaybackHistoryPlaylistImpl _$$PlaybackHistoryPlaylistImplFromJson( + Map json) => + _$PlaybackHistoryPlaylistImpl( + date: DateTime.parse(json['date'] as String), + playlist: PlaylistSimple.fromJson( + Map.from(json['playlist'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryPlaylistImplToJson( + _$PlaybackHistoryPlaylistImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'playlist': instance.playlist.toJson(), + 'runtimeType': instance.$type, + }; + +_$PlaybackHistoryAlbumImpl _$$PlaybackHistoryAlbumImplFromJson(Map json) => + _$PlaybackHistoryAlbumImpl( + date: DateTime.parse(json['date'] as String), + album: + AlbumSimple.fromJson(Map.from(json['album'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryAlbumImplToJson( + _$PlaybackHistoryAlbumImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'album': instance.album.toJson(), + 'runtimeType': instance.$type, + }; + +_$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) => + _$PlaybackHistoryTrackImpl( + date: DateTime.parse(json['date'] as String), + track: Track.fromJson(Map.from(json['track'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryTrackImplToJson( + _$PlaybackHistoryTrackImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'track': instance.track.toJson(), + 'runtimeType': instance.$type, + }; diff --git a/lib/provider/history/summary.dart b/lib/provider/history/summary.dart new file mode 100644 index 00000000..2aa86ac9 --- /dev/null +++ b/lib/provider/history/summary.dart @@ -0,0 +1,62 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +final playbackHistorySummaryProvider = Provider((ref) { + final (:tracks, :albums, :playlists) = + ref.watch(playbackHistoryGroupedProvider); + + final totalDurationListened = tracks.fold( + Duration.zero, + (previousValue, element) => previousValue + element.track.duration!, + ); + + final totalTracksListened = tracks + .whereIndexed( + (i, track) => + i == tracks.lastIndexWhere((e) => e.track.id == track.track.id), + ) + .length; + + final artists = + tracks.map((e) => e.track.artists).expand((e) => e ?? []).toList(); + + final totalArtistsListened = artists + .whereIndexed( + (i, artist) => i == artists.lastIndexWhere((e) => e.id == artist.id), + ) + .length; + + final totalAlbumsListened = albums + .whereIndexed( + (i, album) => + i == albums.lastIndexWhere((e) => e.album.id == album.album.id), + ) + .length; + + final totalPlaylistsListened = playlists + .whereIndexed( + (i, playlist) => + i == + playlists + .lastIndexWhere((e) => e.playlist.id == playlist.playlist.id), + ) + .length; + + final tracksThisMonth = ref.watch( + playbackHistoryTopProvider(HistoryDuration.days30).select((s) => s.tracks), + ); + + final streams = tracksThisMonth.fold(0, (acc, el) => acc + el.count); + + return ( + duration: totalDurationListened, + tracks: totalTracksListened, + artists: totalArtistsListened, + fees: streams * 0.005, // Spotify pays $0.003 to $0.005 + albums: totalAlbumsListened, + playlists: totalPlaylistsListened, + ); +}); diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart new file mode 100644 index 00000000..7d4594f0 --- /dev/null +++ b/lib/provider/history/top.dart @@ -0,0 +1,95 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/history/state.dart'; + +final playbackHistoryTopDurationProvider = + StateProvider((ref) => HistoryDuration.days30); + +final playbackHistoryTopProvider = + Provider.family((ref, HistoryDuration durationState) { + final grouped = ref.watch(playbackHistoryGroupedProvider); + + final duration = switch (durationState) { + HistoryDuration.allTime => const Duration(days: 365 * 2003), + HistoryDuration.days7 => const Duration(days: 7), + HistoryDuration.days30 => const Duration(days: 30), + HistoryDuration.months6 => const Duration(days: 30 * 6), + HistoryDuration.year => const Duration(days: 365), + HistoryDuration.years2 => const Duration(days: 365 * 2), + }; + final tracks = grouped.tracks + .where( + (item) => item.date.isAfter( + DateTime.now().subtract(duration), + ), + ) + .toList(); + final albums = grouped.albums + .where( + (item) => item.date.isAfter( + DateTime.now().subtract(duration), + ), + ) + .toList(); + + final playlists = grouped.playlists + .where( + (item) => item.date.isAfter( + DateTime.now().subtract(duration), + ), + ) + .toList(); + + final tracksWithCount = groupBy( + tracks, + (track) => track.track.id!, + ) + .entries + .map((entry) { + return (count: entry.value.length, track: entry.value.first.track); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + final albumsWithTrackAlbums = [ + for (final historicAlbum in albums) historicAlbum.album, + for (final track in tracks) track.track.album! + ]; + + final albumsWithCount = groupBy(albumsWithTrackAlbums, (album) => album.id!) + .entries + .map((entry) { + return (count: entry.value.length, album: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + final artists = + tracks.map((track) => track.track.artists).expand((e) => e ?? []); + + final artistsWithCount = groupBy(artists, (artist) => artist.id!) + .entries + .map((entry) { + return (count: entry.value.length, artist: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + final playlistsWithCount = + groupBy(playlists, (playlist) => playlist.playlist.id!) + .entries + .map((entry) { + return (count: entry.value.length, playlist: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + return ( + tracks: tracksWithCount, + albums: albumsWithCount, + artists: artistsWithCount, + playlists: playlistsWithCount, + ); +}); diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index bf54fa90..3ee815e6 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -3,24 +3,50 @@ import 'dart:async'; import 'package:catcher_2/catcher_2.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/skip_segments.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; extension ProxyPlaylistListeners on ProxyPlaylistNotifier { + Future updatePalette() async { + final palette = ref.read(paletteProvider); + if (!preferences.albumColorSync) { + if (palette != null) ref.read(paletteProvider.notifier).state = null; + return; + } + return Future.microtask(() async { + if (playlist.activeTrack == null) return; + + final palette = await PaletteGenerator.fromImageProvider( + UniversalImage.imageProvider( + (playlist.activeTrack?.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + height: 50, + width: 50, + ), + ); + ref.read(paletteProvider.notifier).state = palette; + }); + } + StreamSubscription subscribeToPlaylist() { - return audioPlayer.playlistStream.listen((playlist) { - state = state.copyWith( - tracks: playlist.medias + return audioPlayer.playlistStream.listen((mpvPlaylist) { + state = playlist.copyWith( + tracks: mpvPlaylist.medias .map((media) => SpotubeMedia.fromMedia(media).track) .toSet(), - active: playlist.index, + active: mpvPlaylist.index, ); - notificationService.addTrack(state.activeTrack!); - discord.updatePresence(state.activeTrack!); + notificationService.addTrack(playlist.activeTrack!); + discord.updatePresence(playlist.activeTrack!); updatePalette(); }); } @@ -46,17 +72,18 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { String? lastScrobbled; return audioPlayer.positionStream.listen((position) { try { - final uid = state.activeTrack is LocalTrack - ? (state.activeTrack as LocalTrack).path - : state.activeTrack?.id; + final uid = playlist.activeTrack is LocalTrack + ? (playlist.activeTrack as LocalTrack).path + : playlist.activeTrack?.id; - if (state.activeTrack == null || + if (playlist.activeTrack == null || lastScrobbled == uid || position.inSeconds < 30) { return; } - scrobbler.scrobble(state.activeTrack!); + scrobbler.scrobble(playlist.activeTrack!); + history.addTrack(playlist.activeTrack!); lastScrobbled = uid; } catch (e, stack) { Catcher2.reportCheckedError(e, stack); @@ -68,9 +95,9 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { String lastTrack = ""; // used to prevent multiple calls to the same track return audioPlayer.positionStream.listen((event) async { if (event < const Duration(seconds: 3) || - state.active == null || - state.active == state.tracks.length - 1) return; - final nextTrack = state.tracks.elementAt(state.active! + 1); + playlist.active == null || + playlist.active == playlist.tracks.length - 1) return; + final nextTrack = playlist.tracks.elementAt(playlist.active! + 1); if (lastTrack == nextTrack.id || nextTrack is LocalTrack) return; diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index 1378c589..9f371b7a 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -1,6 +1,5 @@ import 'package:collection/collection.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 9811a1f8..c8eb3657 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -2,14 +2,12 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; @@ -32,6 +30,8 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { ProxyPlaylist get playlist => state; BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier); Discord get discord => ref.read(discordProvider); + PlaybackHistoryNotifier get history => + ref.read(playbackHistoryProvider.notifier); List _subscriptions = []; @@ -167,28 +167,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { discord.clear(); } - Future updatePalette() async { - final palette = ref.read(paletteProvider); - if (!preferences.albumColorSync) { - if (palette != null) ref.read(paletteProvider.notifier).state = null; - return; - } - return Future.microtask(() async { - if (state.activeTrack == null) return; - - final palette = await PaletteGenerator.fromImageProvider( - UniversalImage.imageProvider( - (state.activeTrack?.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - height: 50, - width: 50, - ), - ); - ref.read(paletteProvider.notifier).state = palette; - }); - } - @override set state(state) { super.state = state; diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index d34586f3..fe726915 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -6,6 +6,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/provider/palette_provider.dart'; +import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index 95ed4b03..4bcb3a46 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -6,8 +6,7 @@ part of 'user_preferences_state.dart'; // JsonSerializableGenerator // ************************************************************************** -_$UserPreferencesImpl _$$UserPreferencesImplFromJson( - Map json) => +_$UserPreferencesImpl _$$UserPreferencesImplFromJson(Map json) => _$UserPreferencesImpl( audioQuality: $enumDecodeNullable(_$SourceQualitiesEnumMap, json['audioQuality']) ?? diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index d67652b4..8d3e0bfb 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:catcher_2/catcher_2.dart'; import 'package:flutter/foundation.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/services/audio_player/custom_player.dart'; diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 3bb88447..62cc8552 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -11,7 +11,7 @@ class MobileAudioService extends BaseAudioHandler { AudioSession? session; final ProxyPlaylistNotifier playlistNotifier; - // ignore: invalid_use_of_protected_member + // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member ProxyPlaylist get playlist => playlistNotifier.state; MobileAudioService(this.playlistNotifier) { @@ -135,7 +135,7 @@ class MobileAudioService extends BaseAudioHandler { playing: audioPlayer.isPlaying, updatePosition: position, bufferedPosition: await audioPlayer.bufferedPosition ?? Duration.zero, - shuffleMode: await audioPlayer.isShuffled == true + shuffleMode: audioPlayer.isShuffled == true ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, repeatMode: (audioPlayer.loopMode).toAudioServiceRepeatMode(), diff --git a/lib/services/song_link/song_link.g.dart b/lib/services/song_link/song_link.g.dart index 911849e3..7658a74c 100644 --- a/lib/services/song_link/song_link.g.dart +++ b/lib/services/song_link/song_link.g.dart @@ -6,8 +6,7 @@ part of 'song_link.dart'; // JsonSerializableGenerator // ************************************************************************** -_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => - _$SongLinkImpl( +_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => _$SongLinkImpl( displayName: json['displayName'] as String, linkId: json['linkId'] as String, platform: json['platform'] as String, diff --git a/lib/services/sourced_track/models/source_info.g.dart b/lib/services/sourced_track/models/source_info.g.dart index 1ec9f75f..5fe136ce 100644 --- a/lib/services/sourced_track/models/source_info.g.dart +++ b/lib/services/sourced_track/models/source_info.g.dart @@ -6,7 +6,7 @@ part of 'source_info.dart'; // JsonSerializableGenerator // ************************************************************************** -SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( +SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( id: json['id'] as String, title: json['title'] as String, artist: json['artist'] as String, diff --git a/lib/services/sourced_track/models/source_map.g.dart b/lib/services/sourced_track/models/source_map.g.dart index e1085aa8..a581cc67 100644 --- a/lib/services/sourced_track/models/source_map.g.dart +++ b/lib/services/sourced_track/models/source_map.g.dart @@ -6,8 +6,7 @@ part of 'source_map.dart'; // JsonSerializableGenerator // ************************************************************************** -SourceQualityMap _$SourceQualityMapFromJson(Map json) => - SourceQualityMap( +SourceQualityMap _$SourceQualityMapFromJson(Map json) => SourceQualityMap( high: json['high'] as String, medium: json['medium'] as String, low: json['low'] as String, @@ -20,16 +19,18 @@ Map _$SourceQualityMapToJson(SourceQualityMap instance) => 'low': instance.low, }; -SourceMap _$SourceMapFromJson(Map json) => SourceMap( +SourceMap _$SourceMapFromJson(Map json) => SourceMap( weba: json['weba'] == null ? null - : SourceQualityMap.fromJson(json['weba'] as Map), + : SourceQualityMap.fromJson( + Map.from(json['weba'] as Map)), m4a: json['m4a'] == null ? null - : SourceQualityMap.fromJson(json['m4a'] as Map), + : SourceQualityMap.fromJson( + Map.from(json['m4a'] as Map)), ); Map _$SourceMapToJson(SourceMap instance) => { - 'weba': instance.weba, - 'm4a': instance.m4a, + 'weba': instance.weba?.toJson(), + 'm4a': instance.m4a?.toJson(), }; diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index ec3bb0cb..50e92347 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -272,6 +272,22 @@ abstract class ServiceUtils { GoRouter.of(context).go(location, extra: extra); } + static void navigateNamed( + BuildContext context, + String name, { + Object? extra, + Map? pathParameters, + Map? queryParameters, + }) { + if (GoRouterState.of(context).matchedLocation == name) return; + GoRouter.of(context).goNamed( + name, + pathParameters: pathParameters ?? const {}, + queryParameters: queryParameters ?? const {}, + extra: extra, + ); + } + static void push(BuildContext context, String location, {Object? extra}) { final router = GoRouter.of(context); final routerState = GoRouterState.of(context); @@ -283,6 +299,36 @@ abstract class ServiceUtils { router.push(location, extra: extra); } + static void pushNamed( + BuildContext context, + String name, { + Object? extra, + Map pathParameters = const {}, + Map queryParameters = const {}, + }) { + final router = GoRouter.of(context); + final routerState = GoRouterState.of(context); + final routerStack = router.routerDelegate.currentConfiguration.matches + .map((e) => e.matchedLocation); + + final nameLocation = routerState.namedLocation( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + ); + + if (routerState.matchedLocation == nameLocation || + routerStack.contains(nameLocation)) { + return; + } + router.pushNamed( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + extra: extra, + ); + } + static DateTime parseSpotifyAlbumDate(AlbumSimple? album) { if (album == null || album.releaseDate == null) { return DateTime.parse("1975-01-01"); diff --git a/pubspec.lock b/pubspec.lock index 61de3f25..c5688dea 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1455,13 +1455,12 @@ packages: source: hosted version: "1.0.9" media_kit_native_event_loop: - dependency: "direct overridden" + dependency: transitive description: - path: media_kit_native_event_loop - ref: main - resolved-ref: "285f7919bbf4a7d89a62615b14a3766a171ad575" - url: "https://github.com/media-kit/media-kit" - source: git + name: media_kit_native_event_loop + sha256: a605cf185499d14d58935b8784955a92a4bf0ff4e19a23de3d17a9106303930e + url: "https://pub.dev" + source: hosted version: "1.0.8" menu_base: dependency: transitive @@ -2048,11 +2047,12 @@ packages: spotify: dependency: "direct main" description: - name: spotify - sha256: "50bd5a07b580ee441d0b4d81227185ada768332c353671aa7555ea47cc68eb9e" - url: "https://pub.dev" - source: hosted - version: "0.13.5" + path: "." + ref: "fix/explicit-to-json" + resolved-ref: c4b37c599413ac7bfd78993e416a56105c62b634 + url: "https://github.com/KRTirtho/spotify-dart.git" + source: git + version: "0.13.6" sprintf: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index dc60abf6..6ec4a2fc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -115,7 +115,10 @@ dependencies: flutter_sharing_intent: ^1.1.0 flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 - spotify: ^0.13.5 + spotify: + git: + url: https://github.com/KRTirtho/spotify-dart.git + ref: fix/explicit-to-json bonsoir: ^5.1.9 shelf: ^1.4.1 shelf_router: ^1.1.4 @@ -156,11 +159,11 @@ dependency_overrides: git: url: https://github.com/antler119/system_tray ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c - media_kit_native_event_loop: # to fix "macro name must be an identifier" - git: - url: https://github.com/media-kit/media-kit - path: media_kit_native_event_loop - ref: main + # media_kit_native_event_loop: # to fix "macro name must be an identifier" + # git: + # url: https://github.com/media-kit/media-kit + # path: media_kit_native_event_loop + # ref: main flutter: generate: true diff --git a/untranslated_messages.json b/untranslated_messages.json index 3ea0ca23..aaf06929 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -3,181 +3,207 @@ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "bn": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "ca": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "cs": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "de": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "es": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "eu": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "fa": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "fi": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "fr": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "hi": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "id": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "it": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "ja": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "ka": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "ko": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "ne": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "nl": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "pl": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "pt": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "ru": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "th": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "tr": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "uk": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "vi": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ], "zh": [ "local_library", "add_library_location", "remove_library_location", - "local_tab" + "local_tab", + "stats" ] } From d2683c52d81d807be6ff72f15b8e9eb18181e211 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 1 Jun 2024 11:41:35 +0600 Subject: [PATCH 084/261] fix: some text are garbled in different parts of the app #1463 #1505 --- lib/provider/spotify/lyrics/synced.dart | 4 ++-- .../custom_spotify_endpoints/spotify_endpoints.dart | 13 +++++++------ lib/utils/service_utils.dart | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index 066596a9..afb27a6b 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -30,7 +30,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier ); } final linesRaw = Map.castFrom( - jsonDecode(res.body), + jsonDecode(utf8.decode(res.bodyBytes)), )["lyrics"]?["lines"] as List?; final lines = linesRaw?.map((line) { @@ -83,7 +83,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier ); } - final json = jsonDecode(res.body) as Map; + final json = jsonDecode(utf8.decode(res.bodyBytes)) as Map; final syncedLyricsRaw = json["syncedLyrics"] as String?; final syncedLyrics = syncedLyricsRaw?.isNotEmpty == true diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index d8600366..553f6824 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -75,7 +75,7 @@ class CustomSpotifyEndpoints { ); if (res.statusCode == 200) { - return jsonDecode(res.body); + return jsonDecode(utf8.decode(res.bodyBytes)); } else { throw Exception( '[CustomSpotifyEndpoints.getView]: Failed to get view' @@ -96,7 +96,7 @@ class CustomSpotifyEndpoints { ); if (res.statusCode == 200) { - final body = jsonDecode(res.body); + final body = jsonDecode(utf8.decode(res.bodyBytes)); return List.from(body["genres"] ?? []); } else { throw Exception( @@ -160,7 +160,7 @@ class CustomSpotifyEndpoints { "accept": "application/json", }, ); - final result = jsonDecode(res.body); + final result = jsonDecode(utf8.decode(res.bodyBytes)); return List.castFrom( result["tracks"].map((track) => Track.fromJson(track)).toList(), ); @@ -175,7 +175,7 @@ class CustomSpotifyEndpoints { "accept": "application/json", }, ); - return SpotifyFriends.fromJson(jsonDecode(res.body)); + return SpotifyFriends.fromJson(jsonDecode(utf8.decode(res.bodyBytes))); } Future getHomeFeed({ @@ -232,7 +232,7 @@ class CustomSpotifyEndpoints { final data = SpotifyHomeFeed.fromJson( transformHomeFeedJsonMap( - jsonDecode(response.body), + jsonDecode(utf8.decode(response.bodyBytes)), ), ); @@ -293,7 +293,8 @@ class CustomSpotifyEndpoints { final data = SpotifyHomeFeedSection.fromJson( transformSectionItemJsonMap( - jsonDecode(response.body)["data"]["homeSections"]["sections"][0], + jsonDecode(utf8.decode(response.bodyBytes))["data"]["homeSections"] + ["sections"][0], ), ); diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 50e92347..1432eb53 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -115,7 +115,7 @@ abstract class ServiceUtils { Uri.parse(authHeader ? reqUrl : "$reqUrl&access_token=$apiKey"), headers: authHeader ? headers : null, ); - Map data = jsonDecode(response.body)["response"]; + Map data = jsonDecode(utf8.decode(response.bodyBytes))["response"]; if (data["hits"]?.length == 0) return null; List results = data["hits"]?.map((val) { return { From b2d9e647585ea5e834b949307d4de9cb73d6cacc Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 1 Jun 2024 12:31:20 +0600 Subject: [PATCH 085/261] refactor: use replace http with dio and use it as the default --- .../proxy_playlist/skip_segments.dart | 49 +++--- lib/provider/spotify/lyrics/synced.dart | 46 +++--- lib/provider/spotify/spotify.dart | 4 +- .../spotify_endpoints.dart | 146 +++++++----------- lib/services/dio/dio.dart | 3 + lib/utils/service_utils.dart | 47 ++++-- pubspec.yaml | 2 +- 7 files changed, 148 insertions(+), 149 deletions(-) create mode 100644 lib/services/dio/dio.dart diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart index 2d90eea6..7f3d1e9a 100644 --- a/lib/provider/proxy_playlist/skip_segments.dart +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -1,12 +1,11 @@ -import 'dart:convert'; - import 'package:catcher_2/catcher_2.dart'; +import 'package:dio/dio.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/dio/dio.dart'; class SourcedSegments { final String source; @@ -30,29 +29,35 @@ Future> getAndCacheSkipSegments(String id) async { ); } - final res = await get(Uri( - scheme: "https", - host: "sponsor.ajay.app", - path: "/api/skipSegments", - queryParameters: { - "videoID": id, - "category": [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'music_offtopic' - ], - "actionType": 'skip' - }, - )); + final res = await globalDio.getUri( + Uri( + scheme: "https", + host: "sponsor.ajay.app", + path: "/api/skipSegments", + queryParameters: { + "videoID": id, + "category": [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'music_offtopic' + ], + "actionType": 'skip' + }, + ), + options: Options( + responseType: ResponseType.json, + validateStatus: (status) => (status ?? 0) < 500, + ), + ); - if (res.body == "Not Found") { + if (res.data == "Not Found") { return List.castFrom([]); } - final data = jsonDecode(res.body) as List; + final data = res.data as List; final segments = data.map((obj) { final start = obj["segment"].first.toInt(); final end = obj["segment"].last.toInt(); diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index afb27a6b..04a2ddca 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -9,29 +9,34 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier Track get _track => arg!; Future getSpotifyLyrics(String? token) async { - final res = await http.get( - Uri.parse( - "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", - ), + final res = await globalDio.getUri( + Uri.parse( + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", + ), + options: Options( headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", "App-platform": "WebPlayer", "authorization": "Bearer $token" - }); + }, + responseType: ResponseType.json, + validateStatus: (status) => true, + ), + ); if (res.statusCode != 200) { return SubtitleSimple( lyrics: [], name: _track.name!, - uri: res.request!.url, + uri: res.realUri, rating: 0, provider: "Spotify", ); } - final linesRaw = Map.castFrom( - jsonDecode(utf8.decode(res.bodyBytes)), - )["lyrics"]?["lines"] as List?; + final linesRaw = + Map.castFrom(res.data)["lyrics"] + ?["lines"] as List?; final lines = linesRaw?.map((line) { return LyricSlice( @@ -44,7 +49,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: lines, name: _track.name!, - uri: res.request!.url, + uri: res.realUri, rating: 100, provider: "Spotify", ); @@ -55,7 +60,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier Future getLRCLibLyrics() async { final packageInfo = await PackageInfo.fromPlatform(); - final res = await http.get( + final res = await globalDio.getUri( Uri( scheme: "https", host: "lrclib.net", @@ -67,23 +72,26 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier "duration": _track.duration?.inSeconds.toString(), }, ), - headers: { - "User-Agent": - "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" - }, + options: Options( + headers: { + "User-Agent": + "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" + }, + responseType: ResponseType.json, + ), ); if (res.statusCode != 200) { return SubtitleSimple( lyrics: [], name: _track.name!, - uri: res.request!.url, + uri: res.realUri, rating: 0, provider: "LRCLib", ); } - final json = jsonDecode(utf8.decode(res.bodyBytes)) as Map; + final json = res.data as Map; final syncedLyricsRaw = json["syncedLyrics"] as String?; final syncedLyrics = syncedLyricsRaw?.isNotEmpty == true @@ -97,7 +105,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: syncedLyrics!, name: _track.name!, - uri: res.request!.url, + uri: res.realUri, rating: 100, provider: "LRCLib", ); @@ -111,7 +119,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: plainLyrics, name: _track.name!, - uri: res.request!.url, + uri: res.realUri, rating: 0, provider: "LRCLib", ); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index 816420f6..ac83ba72 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -1,10 +1,10 @@ library spotify; import 'dart:async'; -import 'dart:convert'; import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; @@ -23,9 +23,9 @@ import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/wikipedia/wikipedia.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:http/http.dart' as http; import 'package:wikipedia_api/wikipedia_api.dart'; diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index 553f6824..4bc78f8a 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:http/http.dart' as http; +import 'package:dio/dio.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; @@ -9,9 +9,21 @@ import 'package:timezone/timezone.dart' as tz; class CustomSpotifyEndpoints { static const _baseUrl = 'https://api.spotify.com/v1'; final String accessToken; - final http.Client _client; + final Dio _client; - CustomSpotifyEndpoints(this.accessToken) : _client = http.Client(); + CustomSpotifyEndpoints(this.accessToken) + : _client = Dio( + BaseOptions( + baseUrl: _baseUrl, + responseType: ResponseType.json, + headers: { + "content-type": "application/json", + if (accessToken.isNotEmpty) + "authorization": "Bearer $accessToken", + "accept": "application/json", + }, + ), + ); // views API @@ -65,44 +77,34 @@ class CustomSpotifyEndpoints { if (country != null) 'country': country.name, }.entries.map((e) => '${e.key}=${e.value}').join('&'); - final res = await _client.get( + final res = await _client.getUri( Uri.parse('$_baseUrl/views/$view?$queryParams'), - headers: { - "content-type": "application/json", - "authorization": "Bearer $accessToken", - "accept": "application/json", - }, ); if (res.statusCode == 200) { - return jsonDecode(utf8.decode(res.bodyBytes)); + return res.data; } else { throw Exception( '[CustomSpotifyEndpoints.getView]: Failed to get view' '\nStatus code: ${res.statusCode}' - '\nBody: ${res.body}', + '\nBody: ${res.data}', ); } } Future> listGenreSeeds() async { - final res = await _client.get( + final res = await _client.getUri( Uri.parse("$_baseUrl/recommendations/available-genre-seeds"), - headers: { - "content-type": "application/json", - if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", - "accept": "application/json", - }, ); if (res.statusCode == 200) { - final body = jsonDecode(utf8.decode(res.bodyBytes)); + final body = res.data; return List.from(body["genres"] ?? []); } else { throw Exception( '[CustomSpotifyEndpoints.listGenreSeeds]: Failed to get genre seeds' '\nStatus code: ${res.statusCode}' - '\nBody: ${res.body}', + '\nBody: ${res.data}', ); } } @@ -152,30 +154,18 @@ class CustomSpotifyEndpoints { } final pathQuery = "$_baseUrl/recommendations?${parameters.entries.map((e) => '${e.key}=${e.value}').join('&')}"; - final res = await _client.get( - Uri.parse(pathQuery), - headers: { - "content-type": "application/json", - if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", - "accept": "application/json", - }, - ); - final result = jsonDecode(utf8.decode(res.bodyBytes)); + final res = await _client.getUri(Uri.parse(pathQuery)); + final result = res.data; return List.castFrom( result["tracks"].map((track) => Track.fromJson(track)).toList(), ); } Future getFriendActivity() async { - final res = await _client.get( + final res = await _client.getUri( Uri.parse("https://guc-spclient.spotify.com/presence-view/v1/buddylist"), - headers: { - "content-type": "application/json", - "authorization": "Bearer $accessToken", - "accept": "application/json", - }, ); - return SpotifyFriends.fromJson(jsonDecode(utf8.decode(res.bodyBytes))); + return SpotifyFriends.fromJson(res.data); } Future getHomeFeed({ @@ -190,50 +180,39 @@ class CustomSpotifyEndpoints { 'origin': 'https://open.spotify.com', 'referer': 'https://open.spotify.com/' }; - final response = await http.get( - Uri( - scheme: "https", - host: "api-partner.spotify.com", - path: "/pathfinder/v1/query", - queryParameters: { - "operationName": "home", - "variables": jsonEncode({ - "timeZone": tz.local.name, - "sp_t": spTCookie, - "country": country.name, - "facet": null, - "sectionItemsLimit": 10 - }), - "extensions": jsonEncode( - { - "persistedQuery": { - "version": 1, + final response = await _client.getUri( + Uri( + scheme: "https", + host: "api-partner.spotify.com", + path: "/pathfinder/v1/query", + queryParameters: { + "operationName": "home", + "variables": jsonEncode({ + "timeZone": tz.local.name, + "sp_t": spTCookie, + "country": country.name, + "facet": null, + "sectionItemsLimit": 10 + }), + "extensions": jsonEncode( + { + "persistedQuery": { + "version": 1, - /// GraphQL persisted Query hash - /// This can change overtime. We've to lookout for it - /// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/ - "sha256Hash": - "eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be", - } - }, - ), - }, - ), - headers: headers, - ); - - if (response.statusCode >= 400) { - throw Exception( - "[RequestException] " - "Status: ${response.statusCode}\n" - "Body: ${response.body}", - ); - } + /// GraphQL persisted Query hash + /// This can change overtime. We've to lookout for it + /// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/ + "sha256Hash": + "eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be", + } + }, + ), + }, + ), + options: Options(headers: headers)); final data = SpotifyHomeFeed.fromJson( - transformHomeFeedJsonMap( - jsonDecode(utf8.decode(response.bodyBytes)), - ), + transformHomeFeedJsonMap(response.data), ); return data; @@ -252,7 +231,7 @@ class CustomSpotifyEndpoints { 'origin': 'https://open.spotify.com', 'referer': 'https://open.spotify.com/' }; - final response = await http.get( + final response = await _client.getUri( Uri( scheme: "https", host: "api-partner.spotify.com", @@ -280,21 +259,12 @@ class CustomSpotifyEndpoints { ), }, ), - headers: headers, + options: Options(headers: headers), ); - if (response.statusCode >= 400) { - throw Exception( - "[RequestException] " - "Status: ${response.statusCode}\n" - "Body: ${response.body}", - ); - } - final data = SpotifyHomeFeedSection.fromJson( transformSectionItemJsonMap( - jsonDecode(utf8.decode(response.bodyBytes))["data"]["homeSections"] - ["sections"][0], + response.data["data"]["homeSections"]["sections"][0], ), ); diff --git a/lib/services/dio/dio.dart b/lib/services/dio/dio.dart new file mode 100644 index 00000000..cddf1979 --- /dev/null +++ b/lib/services/dio/dio.dart @@ -0,0 +1,3 @@ +import 'package:dio/dio.dart'; + +final globalDio = Dio(); diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 1432eb53..aa2cd985 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -1,13 +1,12 @@ -import 'dart:convert'; - +import 'package:dio/dio.dart'; import 'package:go_router/go_router.dart'; import 'package:html/dom.dart' hide Text; import 'package:spotify/spotify.dart'; import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/components/root/update_dialog.dart'; import 'package:spotube/models/logger.dart'; -import 'package:http/http.dart' as http; import 'package:spotube/models/lyrics.dart'; +import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; @@ -70,9 +69,12 @@ abstract class ServiceUtils { } static Future extractLyrics(Uri url) async { - final response = await http.get(url); + final response = await globalDio.getUri( + url, + options: Options(responseType: ResponseType.plain), + ); - Document document = parser.parse(response.body); + Document document = parser.parse(response.data); String? lyrics = document.querySelector('div.lyrics')?.text.trim(); if (lyrics == null) { lyrics = ""; @@ -111,11 +113,14 @@ abstract class ServiceUtils { String reqUrl = "$searchUrl${Uri.encodeComponent(song)}"; Map headers = {"Authorization": 'Bearer $apiKey'}; - final response = await http.get( + final response = await globalDio.getUri( Uri.parse(authHeader ? reqUrl : "$reqUrl&access_token=$apiKey"), - headers: authHeader ? headers : null, + options: Options( + headers: authHeader ? headers : null, + responseType: ResponseType.json, + ), ); - Map data = jsonDecode(utf8.decode(response.bodyBytes))["response"]; + Map data = response.data["response"]; if (data["hits"]?.length == 0) return null; List results = data["hits"]?.map((val) { return { @@ -195,8 +200,11 @@ abstract class ServiceUtils { queryParameters: {"q": query}, ); - final res = await http.get(searchUri); - final document = parser.parse(res.body); + final res = await globalDio.getUri( + searchUri, + options: Options(responseType: ResponseType.plain), + ); + final document = parser.parse(res.data); final results = document.querySelectorAll("#tablecontainer table tbody tr td a"); @@ -229,7 +237,11 @@ abstract class ServiceUtils { logger.v("[Selected subtitle] ${topResult.text} | $subtitleUri"); - final lrcDocument = parser.parse((await http.get(subtitleUri)).body); + final lrcDocument = parser.parse((await globalDio.getUri( + subtitleUri, + options: Options(responseType: ResponseType.plain), + )) + .data); final lrcList = lrcDocument .querySelector("#ctl00_ContentPlaceHolder1_lbllyrics") ?.innerHtml @@ -384,14 +396,16 @@ abstract class ServiceUtils { final packageInfo = await PackageInfo.fromPlatform(); if (Env.releaseChannel == ReleaseChannel.nightly) { - final value = await http.get( + final value = await globalDio.getUri( Uri.parse( "https://api.github.com/repos/KRTirtho/spotube/actions/workflows/spotube-release-binary.yml/runs?status=success&per_page=1", ), + options: Options( + responseType: ResponseType.json, + ), ); - final buildNum = - jsonDecode(value.body)["workflow_runs"][0]["run_number"] as int; + final buildNum = value.data["workflow_runs"][0]["run_number"] as int; if (buildNum <= int.parse(packageInfo.buildNumber) || !context.mounted) { return; @@ -406,13 +420,12 @@ abstract class ServiceUtils { }, ); } else { - final value = await http.get( + final value = await globalDio.getUri( Uri.parse( "https://api.github.com/repos/KRTirtho/spotube/releases/latest", ), ); - final tagName = - (jsonDecode(value.body)["tag_name"] as String).replaceAll("v", ""); + final tagName = (value.data["tag_name"] as String).replaceAll("v", ""); final currentVersion = packageInfo.version == "Unknown" ? null : Version.parse(packageInfo.version); diff --git a/pubspec.yaml b/pubspec.yaml index 6ec4a2fc..c3ab2a53 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,7 +55,6 @@ dependencies: hive_flutter: ^1.1.0 hooks_riverpod: ^2.5.1 html: ^0.15.1 - http: ^1.2.0 image_picker: ^1.1.0 intl: ^0.18.0 introduction_screen: ^3.1.14 @@ -131,6 +130,7 @@ dependencies: crypto: ^3.0.3 local_notifier: ^0.1.6 tray_manager: ^0.2.2 + http: ^1.2.1 dev_dependencies: build_runner: ^2.4.9 From e1786989ffbab9d045f14f25fb62a3b72ec19774 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 1 Jun 2024 12:36:00 +0600 Subject: [PATCH 086/261] cd: use dio in cli as well --- cli/commands/credits.dart | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cli/commands/credits.dart b/cli/commands/credits.dart index 66ec1172..6bad7a44 100644 --- a/cli/commands/credits.dart +++ b/cli/commands/credits.dart @@ -2,13 +2,19 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:collection/collection.dart'; -import 'package:http/http.dart'; +import 'package:dio/dio.dart'; import 'package:html/parser.dart'; import 'package:path/path.dart'; import 'package:pub_api_client/pub_api_client.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; class CreditsCommand extends Command { + final dio = Dio( + BaseOptions( + responseType: ResponseType.plain, + ), + ); + @override String get description => "Generate credits for used Library's authors"; @@ -66,11 +72,11 @@ class CreditsCommand extends Command { final gitPubspecs = await Future.wait( gitDeps.map( (d) { - Pubspec parser(res) { + Pubspec parser(Response res) { try { - return Pubspec.parse(res.body); + return Pubspec.parse(res.data); } catch (e) { - final document = parse(res.body); + final document = parse(res.data); final pre = document.querySelector('pre'); if (pre == null) { stdout.writeln(d.toString()); @@ -80,8 +86,9 @@ class CreditsCommand extends Command { } } - return get(Uri.parse(d.value)).then(parser).catchError( - (_) => get(Uri.parse(d.value.replaceFirst('/main', '/master'))) + return dio.get(d.value).then(parser).catchError( + (_) => dio + .get(d.value.replaceFirst('/main', '/master')) .then(parser), ); }, From e034455173df8d97c70dfa849ce3eaa99f3c0c66 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 1 Jun 2024 12:47:36 +0600 Subject: [PATCH 087/261] chore: fix home feed not showing up --- lib/provider/authentication_provider.dart | 5 +- .../spotify_endpoints.dart | 57 ++++++++++--------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index c94f4f3e..be61cb4f 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -52,8 +52,9 @@ class AuthenticationCredentials { headers: { "Cookie": spDc ?? "", "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" }, + validateStatus: (status) => true, ), ); final body = res.data; @@ -65,7 +66,7 @@ class AuthenticationCredentials { } return AuthenticationCredentials( - cookie: "${res.headers["set-cookie"]}; $spDc", + cookie: "${res.headers["set-cookie"]?.join(";")}; $spDc", accessToken: body['accessToken'], expiration: DateTime.fromMillisecondsSinceEpoch( body['accessTokenExpirationTimestampMs'], diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index 4bc78f8a..0c7daeb2 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -181,35 +181,36 @@ class CustomSpotifyEndpoints { 'referer': 'https://open.spotify.com/' }; final response = await _client.getUri( - Uri( - scheme: "https", - host: "api-partner.spotify.com", - path: "/pathfinder/v1/query", - queryParameters: { - "operationName": "home", - "variables": jsonEncode({ - "timeZone": tz.local.name, - "sp_t": spTCookie, - "country": country.name, - "facet": null, - "sectionItemsLimit": 10 - }), - "extensions": jsonEncode( - { - "persistedQuery": { - "version": 1, + Uri( + scheme: "https", + host: "api-partner.spotify.com", + path: "/pathfinder/v1/query", + queryParameters: { + "operationName": "home", + "variables": jsonEncode({ + "timeZone": tz.local.name, + "sp_t": spTCookie, + "country": country.name, + "facet": null, + "sectionItemsLimit": 10 + }), + "extensions": jsonEncode( + { + "persistedQuery": { + "version": 1, - /// GraphQL persisted Query hash - /// This can change overtime. We've to lookout for it - /// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/ - "sha256Hash": - "eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be", - } - }, - ), - }, - ), - options: Options(headers: headers)); + /// GraphQL persisted Query hash + /// This can change overtime. We've to lookout for it + /// Docs: https://www.apollographql.com/docs/graphos/operations/persisted-queries/ + "sha256Hash": + "eb3fba2d388cf4fc4d696b1757a58584e9538a3b515ea742e9cc9465807340be", + } + }, + ), + }, + ), + options: Options(headers: headers), + ); final data = SpotifyHomeFeed.fromJson( transformHomeFeedJsonMap(response.data), From c4023aa09de56c19110de6c6883951459aef692b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 1 Jun 2024 13:05:16 +0600 Subject: [PATCH 088/261] chore: downloaded tracks folder not opening --- lib/components/library/local_folder/local_folder_item.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/components/library/local_folder/local_folder_item.dart index 556f09a6..72032198 100644 --- a/lib/components/library/local_folder/local_folder_item.dart +++ b/lib/components/library/local_folder/local_folder_item.dart @@ -61,7 +61,7 @@ class LocalFolderItem extends HookConsumerWidget { context.goNamed( LocalLibraryPage.name, queryParameters: { - if (isDownloadFolder) "downloads": 1, + if (isDownloadFolder) "downloads": "true", }, extra: folder, ); From 02acbd93271145dde365f6c547e0d9d902be65f1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 1 Jun 2024 15:45:06 +0600 Subject: [PATCH 089/261] feat: play initially available tracks of playlist/album immediately and fetch rest in background #670 --- lib/components/playlist/playlist_card.dart | 40 ++++++++++++++----- .../sections/header/header_buttons.dart | 25 +++++++++--- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 72e13b26..9f26f739 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -36,12 +36,23 @@ class PlaylistCard extends HookConsumerWidget { final updating = useState(false); final me = ref.watch(meProvider); - Future> fetchAllTracks() async { + Future> fetchInitialTracks() async { if (playlist.id == 'user-liked-tracks') { return await ref.read(likedTracksProvider.future); } - await ref.read(playlistTracksProvider(playlist.id!).future); + final result = + await ref.read(playlistTracksProvider(playlist.id!).future); + + return result.items; + } + + Future> fetchAllTracks() async { + final initialTracks = await fetchInitialTracks(); + + if (playlist.id == 'user-liked-tracks') { + return initialTracks; + } return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); } @@ -77,23 +88,29 @@ class PlaylistCard extends HookConsumerWidget { return audioPlayer.resume(); } - List fetchedTracks = await fetchAllTracks(); + final fetchedInitialTracks = await fetchInitialTracks(); - if (fetchedTracks.isEmpty || !context.mounted) return; + if (fetchedInitialTracks.isEmpty || !context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); + final allTracks = await fetchAllTracks(); await remotePlayback.load( WebSocketLoadEventData.playlist( - tracks: fetchedTracks, + tracks: allTracks, collection: playlist, ), ); } else { - await playlistNotifier.load(fetchedTracks, autoPlay: true); + await playlistNotifier.load(fetchedInitialTracks, autoPlay: true); playlistNotifier.addCollection(playlist.id!); historyNotifier.addPlaylists([playlist]); + + final allTracks = await fetchAllTracks(); + + await playlistNotifier + .addTracks(allTracks.sublist(fetchedInitialTracks.length)); } } finally { if (context.mounted) { @@ -106,21 +123,22 @@ class PlaylistCard extends HookConsumerWidget { try { if (isPlaylistPlaying) return; - final fetchedTracks = await fetchAllTracks(); + final fetchedInitialTracks = await fetchAllTracks(); - if (fetchedTracks.isEmpty) return; + if (fetchedInitialTracks.isEmpty) return; - playlistNotifier.addTracks(fetchedTracks); + playlistNotifier.addTracks(fetchedInitialTracks); playlistNotifier.addCollection(playlist.id!); historyNotifier.addPlaylists([playlist]); if (context.mounted) { final snackbar = SnackBar( - content: Text("Added ${fetchedTracks.length} tracks to queue"), + content: + Text("Added ${fetchedInitialTracks.length} tracks to queue"), action: SnackBarAction( label: "Undo", onPressed: () { playlistNotifier - .removeTracks(fetchedTracks.map((e) => e.id!)); + .removeTracks(fetchedInitialTracks.map((e) => e.id!)); }, ), ); diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index 5ffff512..5cc442cf 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -47,12 +47,12 @@ class TrackViewHeaderButtons extends HookConsumerWidget { try { isLoading.value = true; - final allTracks = await props.pagination.onFetchAll(); - + final initialTracks = props.tracks; if (!context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { + final allTracks = await props.pagination.onFetchAll(); final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( props.collection is AlbumSimple @@ -69,9 +69,9 @@ class TrackViewHeaderButtons extends HookConsumerWidget { await remotePlayback.setShuffle(true); } else { await playlistNotifier.load( - allTracks, + initialTracks, autoPlay: true, - initialIndex: Random().nextInt(allTracks.length), + initialIndex: Random().nextInt(initialTracks.length), ); await audioPlayer.setShuffle(true); playlistNotifier.addCollection(props.collectionId); @@ -80,6 +80,12 @@ class TrackViewHeaderButtons extends HookConsumerWidget { } else { historyNotifier.addPlaylists([props.collection as PlaylistSimple]); } + + final allTracks = await props.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); } } finally { isLoading.value = false; @@ -90,12 +96,13 @@ class TrackViewHeaderButtons extends HookConsumerWidget { try { isLoading.value = true; - final allTracks = await props.pagination.onFetchAll(); + final initialTracks = props.tracks; if (!context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { + final allTracks = await props.pagination.onFetchAll(); final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( props.collection is AlbumSimple @@ -109,13 +116,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget { ), ); } else { - await playlistNotifier.load(allTracks, autoPlay: true); + await playlistNotifier.load(initialTracks, autoPlay: true); playlistNotifier.addCollection(props.collectionId); if (props.collection is AlbumSimple) { historyNotifier.addAlbums([props.collection as AlbumSimple]); } else { historyNotifier.addPlaylists([props.collection as PlaylistSimple]); } + + final allTracks = await props.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); } } finally { isLoading.value = false; From 71341ec0bda6ed985b43836712075b97a2cf8bac Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 1 Jun 2024 21:33:05 +0600 Subject: [PATCH 090/261] feat: upgrade to Flutter 3.22.0 --- .fvm/fvm_config.json | 2 +- .github/workflows/spotube-release-binary.yml | 2 +- android/app/src/main/AndroidManifest.xml | 5 ++ devtools_options.yaml | 1 + lib/main.dart | 21 +------ pubspec.lock | 60 +++++--------------- pubspec.yaml | 3 +- 7 files changed, 26 insertions(+), 68 deletions(-) create mode 100644 devtools_options.yaml diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index d42a42fa..6a56dfc6 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.19.5", + "flutterSdkVersion": "3.22.0", "flavors": {} } \ No newline at end of file diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 694dc1eb..eb62b58d 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -20,7 +20,7 @@ on: description: Dry run without uploading to release env: - FLUTTER_VERSION: 3.19.5 + FLUTTER_VERSION: 3.22.0 permissions: contents: write diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5ab7a0b5..52547f04 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,6 +24,11 @@ android:usesCleartextTraffic="true" android:requestLegacyExternalStorage="true" > + + + main(List rawArgs) async { ), runAppFunction: () { runApp( - ProviderScope( - child: DevicePreview( - availableLocales: L10n.all, - enabled: false, - data: const DevicePreviewData( - isEnabled: false, - orientation: Orientation.portrait, - ), - builder: (context) { - return const Spotube(); - }, - ), - ), + const ProviderScope(child: Spotube()), ); }, ); @@ -230,10 +217,8 @@ class SpotubeState extends ConsumerState { debugShowCheckedModeBanner: false, title: 'Spotube', builder: (context, child) { - return DevicePreview.appBuilder( - context, - kIsDesktop && !kIsMacOS ? DragToResizeArea(child: child!) : child, - ); + if (kIsDesktop && !kIsMacOS) return DragToResizeArea(child: child!); + return child!; }, themeMode: themeMode, theme: lightTheme, diff --git a/pubspec.lock b/pubspec.lock index c5688dea..32da1f8d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -466,14 +466,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" - device_frame: - dependency: transitive - description: - name: device_frame - sha256: afe76182aec178d171953d9b4a50a43c57c7cf3c77d8b09a48bf30c8fa04dd9d - url: "https://pub.dev" - source: hosted - version: "1.1.0" device_info_plus: dependency: "direct main" description: @@ -490,14 +482,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" - device_preview: - dependency: "direct main" - description: - name: device_preview - sha256: "2f097bf31b929e15e6756dbe0ec1bcb63952ab9ed51c25dc5a2c722d2b21fdaf" - url: "https://pub.dev" - source: hosted - version: "1.1.0" dio: dependency: "direct main" description: @@ -1258,10 +1242,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.19.0" introduction_screen: dependency: "direct main" description: @@ -1314,26 +1298,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -1474,10 +1458,10 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" metadata_god: dependency: "direct main" description: @@ -1494,14 +1478,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" oauth2: dependency: transitive description: @@ -1734,14 +1710,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.2" - provider: - dependency: transitive - description: - name: provider - sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c - url: "https://pub.dev" - source: hosted - version: "6.1.2" pub_api_client: dependency: "direct main" description: @@ -2178,10 +2146,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" time: dependency: transitive description: @@ -2386,10 +2354,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c3ab2a53..56c25dd7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -26,7 +26,6 @@ dependencies: curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 device_info_plus: ^10.1.0 - device_preview: ^1.1.0 dio: ^5.4.3+1 disable_battery_optimization: ^1.1.1 duration: ^3.0.12 @@ -56,7 +55,7 @@ dependencies: hooks_riverpod: ^2.5.1 html: ^0.15.1 image_picker: ^1.1.0 - intl: ^0.18.0 + intl: any introduction_screen: ^3.1.14 json_annotation: ^4.8.1 logger: ^2.0.2 From 56241f773a53b91ab9652a1e25cba7fb6ec85c9c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 2 Jun 2024 21:15:11 +0600 Subject: [PATCH 091/261] refactor: migrate deprecated warnings --- lib/components/artist/artist_card.dart | 12 ++++----- .../home/sections/friends/friend_item.dart | 2 +- lib/components/home/sections/genres.dart | 2 +- .../local_folder/local_folder_item.dart | 2 +- .../playlist_generate/multi_select_field.dart | 2 +- lib/components/player/player_queue.dart | 3 ++- .../player/sibling_tracks_sheet.dart | 3 ++- lib/components/root/bottom_player.dart | 11 +++----- lib/components/root/sidebar.dart | 10 ++----- .../root/spotube_navigation_bar.dart | 2 +- .../settings/color_scheme_picker_dialog.dart | 4 +-- .../adaptive/adaptive_pop_sheet_list.dart | 2 +- .../shared/links/anchor_button.dart | 2 +- .../shared/page_window_title_bar.dart | 12 ++++----- lib/components/shared/playbutton_card.dart | 14 +++++----- .../shared/themed_button_tab_bar.dart | 2 +- lib/components/stats/common/album_item.dart | 2 +- lib/main.dart | 23 +++------------- lib/pages/desktop_login/desktop_login.dart | 2 +- lib/pages/lyrics/lyrics.dart | 2 +- lib/pages/lyrics/mini_lyrics.dart | 12 ++++----- lib/pages/search/search.dart | 6 ++--- lib/pages/settings/blacklist.dart | 1 - lib/pages/settings/sections/about.dart | 7 +++-- lib/pages/settings/sections/accounts.dart | 2 +- lib/pages/settings/sections/downloads.dart | 1 - lib/themes/theme.dart | 27 ++++++++++++------- 27 files changed, 74 insertions(+), 96 deletions(-) diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 57971ada..9c1ee14a 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -35,6 +35,10 @@ class ArtistCard extends HookConsumerWidget { final radius = BorderRadius.circular(15); + final bgColor = useBrightnessValue( + theme.colorScheme.surface, + theme.colorScheme.surfaceContainerHigh, + ); final double size = useBreakpointValue( xs: 130, sm: 130, @@ -46,12 +50,8 @@ class ArtistCard extends HookConsumerWidget { width: size, margin: const EdgeInsets.symmetric(vertical: 5), child: Material( - shadowColor: theme.colorScheme.background, - color: Color.lerp( - theme.colorScheme.surfaceVariant, - theme.colorScheme.surface, - useBrightnessValue(.9, .7), - ), + shadowColor: theme.colorScheme.surface, + color: bgColor, elevation: 3, shape: RoundedRectangleBorder( borderRadius: radius, diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart index 2b575756..096964a6 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/components/home/sections/friends/friend_item.dart @@ -30,7 +30,7 @@ class FriendItem extends HookConsumerWidget { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surfaceVariant.withOpacity(0.3), + color: colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(15), ), constraints: const BoxConstraints( diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index 7dfafd5a..62f462e2 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -134,7 +134,7 @@ class HomeGenresSection extends HookConsumerWidget { child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), - color: colorScheme.surfaceVariant, + color: colorScheme.surfaceContainerHighest, gradient: categoriesQuery.isLoading ? null : gradient, ), padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/components/library/local_folder/local_folder_item.dart index 72032198..6220a967 100644 --- a/lib/components/library/local_folder/local_folder_item.dart +++ b/lib/components/library/local_folder/local_folder_item.dart @@ -71,7 +71,7 @@ class LocalFolderItem extends HookConsumerWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Color.lerp( - colorScheme.surfaceVariant, + colorScheme.surfaceContainerHighest, colorScheme.surface, lerpValue, ), diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/components/library/playlist_generate/multi_select_field.dart index e54fc2ba..d8e0506d 100644 --- a/lib/components/library/playlist_generate/multi_select_field.dart +++ b/lib/components/library/playlist_generate/multi_select_field.dart @@ -71,7 +71,7 @@ class MultiSelectField extends HookWidget { : theme.colorScheme.onSurface.withOpacity(0.1), ), ), - mouseCursor: MaterialStateMouseCursor.textable, + mouseCursor: WidgetStateMouseCursor.textable, onPressed: !enabled ? null : () async { diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 914d7bc9..1665b3dd 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -122,7 +122,8 @@ class PlayerQueue extends HookConsumerWidget { top: 5.0, ), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), + color: + theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), borderRadius: borderRadius, ), child: CallbackShortcuts( diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 99b7b430..0575d8eb 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -208,7 +208,8 @@ class SiblingTracksSheet extends HookConsumerWidget { : mediaQuery.size.height * .6, decoration: BoxDecoration( borderRadius: borderRadius, - color: theme.colorScheme.surfaceVariant.withOpacity(.5), + color: + theme.colorScheme.surfaceContainerHighest.withOpacity(.5), ), child: Scaffold( backgroundColor: Colors.transparent, diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 5429e172..b99318df 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -14,7 +14,6 @@ import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; @@ -49,12 +48,6 @@ class BottomPlayer extends HookConsumerWidget { ); final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceVariant; - - final bgColor = useBrightnessValue( - Color.lerp(bg, Colors.white, 0.7), - Color.lerp(bg, Colors.black, 0.45)!, - ); // returning an empty non spacious Container as the overlay will take // place in the global overlay stack aka [_entries] @@ -67,7 +60,9 @@ class BottomPlayer extends HookConsumerWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: DecoratedBox( - decoration: BoxDecoration(color: bgColor?.withOpacity(0.8)), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer.withOpacity(.8), + ), child: Material( type: MaterialType.transparency, textStyle: theme.textTheme.bodyMedium!, diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 0e644a89..4fa14021 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -14,7 +14,6 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/pages/settings/settings.dart'; @@ -70,12 +69,7 @@ class Sidebar extends HookConsumerWidget { ); final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceVariant; - - final bgColor = useBrightnessValue( - Color.lerp(bg, Colors.white, 0.7), - Color.lerp(bg, Colors.black, 0.45)!, - ); + final bg = theme.colorScheme.surfaceContainer; useEffect(() { if (!context.mounted) return; @@ -159,7 +153,7 @@ class Sidebar extends HookConsumerWidget { ), padding: const EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( - color: bgColor?.withOpacity(0.8), + color: bg, borderRadius: const BorderRadius.only( topRight: Radius.circular(10), bottomRight: Radius.circular(10), diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index e16ad1a8..3d0c7c75 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -68,7 +68,7 @@ class SpotubeNavigationBar extends HookConsumerWidget { backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.72), buttonBackgroundColor: buttonColor, - color: theme.colorScheme.background, + color: theme.colorScheme.surface, height: panelHeight, animationDuration: const Duration(milliseconds: 350), items: navbarTileList.map( diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart index 8d098375..579f5a29 100644 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ b/lib/components/settings/color_scheme_picker_dialog.dart @@ -179,9 +179,9 @@ class ColorTile extends StatelessWidget { colorScheme.primaryContainer, colorScheme.secondary, colorScheme.secondaryContainer, - colorScheme.background, colorScheme.surface, - colorScheme.surfaceVariant, + colorScheme.surface, + colorScheme.surfaceContainerHighest, colorScheme.onPrimary, colorScheme.onSurface, ]; diff --git a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart index 21f56a22..ce7d3b8c 100644 --- a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart @@ -187,7 +187,7 @@ class AdaptivePopSheetList extends StatelessWidget { icon: icon ?? const Icon(SpotubeIcons.moreVertical), tooltip: tooltip, style: theme.iconButtonTheme.style?.copyWith( - shape: MaterialStatePropertyAll( + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: borderRadius, ), diff --git a/lib/components/shared/links/anchor_button.dart b/lib/components/shared/links/anchor_button.dart index d78bbf96..c6f0b889 100644 --- a/lib/components/shared/links/anchor_button.dart +++ b/lib/components/shared/links/anchor_button.dart @@ -29,7 +29,7 @@ class AnchorButton extends HookWidget { onTapUp: (event) => tap.value = false, onTap: onTap, child: MouseRegion( - cursor: MaterialStateMouseCursor.clickable, + cursor: WidgetStateMouseCursor.clickable, child: Text( text, style: style.copyWith( diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index f19757f3..66709844 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -206,16 +206,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { final theme = Theme.of(context); final colors = WindowButtonColors( normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onBackground, - mouseOver: theme.colorScheme.onBackground.withOpacity(0.1), - mouseDown: theme.colorScheme.onBackground.withOpacity(0.2), - iconMouseOver: theme.colorScheme.onBackground, - iconMouseDown: theme.colorScheme.onBackground, + iconNormal: foregroundColor ?? theme.colorScheme.onSurface, + mouseOver: theme.colorScheme.onSurface.withOpacity(0.1), + mouseDown: theme.colorScheme.onSurface.withOpacity(0.2), + iconMouseOver: theme.colorScheme.onSurface, + iconMouseDown: theme.colorScheme.onSurface, ); final closeColors = WindowButtonColors( normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onBackground, + iconNormal: foregroundColor ?? theme.colorScheme.onSurface, mouseOver: Colors.red, mouseDown: Colors.red[800]!, iconMouseOver: Colors.white, diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index 80a27eb0..807628b3 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -53,6 +53,10 @@ class PlaybuttonCard extends HookWidget { final mediaQuery = MediaQuery.of(context); final radius = BorderRadius.circular(15); + final bgColor = useBrightnessValue( + theme.colorScheme.surface, + theme.colorScheme.surfaceContainerHigh, + ); final double size = useBreakpointValue( xs: 130, sm: 130, @@ -72,13 +76,9 @@ class PlaybuttonCard extends HookWidget { constraints: BoxConstraints(maxWidth: size), margin: margin, child: Material( - color: Color.lerp( - theme.colorScheme.surfaceVariant, - theme.colorScheme.surface, - useBrightnessValue(.9, .7), - ), + color: bgColor, borderRadius: radius, - shadowColor: theme.colorScheme.background, + shadowColor: theme.colorScheme.surface, elevation: 3, child: InkWell( mouseCursor: SystemMouseCursors.click, @@ -158,7 +158,7 @@ class PlaybuttonCard extends HookWidget { Skeleton.keep( child: IconButton( style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.background, + backgroundColor: theme.colorScheme.surface, foregroundColor: theme.colorScheme.primary, minimumSize: const Size.square(10), ), diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index b21ca992..c245e5f4 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -34,7 +34,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { ), borderWidth: 0, unselectedDecoration: BoxDecoration( - color: theme.colorScheme.background, + color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(15), ), unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith( diff --git a/lib/components/stats/common/album_item.dart b/lib/components/stats/common/album_item.dart index ccc0fa4e..00b1cbfe 100644 --- a/lib/components/stats/common/album_item.dart +++ b/lib/components/stats/common/album_item.dart @@ -33,7 +33,7 @@ class StatsAlbumItem extends StatelessWidget { Text("${album.albumType?.formatted} • "), Flexible( child: ArtistLink( - artists: album.artists!, + artists: album.artists ?? [], mainAxisAlignment: WrapAlignment.start, ), ), diff --git a/lib/main.dart b/lib/main.dart index 52d0b141..1693d9d8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:local_notifier/local_notifier.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'; @@ -139,28 +138,11 @@ Future main(List rawArgs) async { ); } -class Spotube extends StatefulHookConsumerWidget { +class Spotube extends HookConsumerWidget { const Spotube({super.key}); @override - SpotubeState createState() => SpotubeState(); - - static SpotubeState of(BuildContext context) => - context.findAncestorStateOfType()!; -} - -class SpotubeState extends ConsumerState { - final logger = getLogger(Spotube); - SharedPreferences? localStorage; - - @override - void initState() { - super.initState(); - SharedPreferences.getInstance().then(((value) => localStorage = value)); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, ref) { final themeMode = ref.watch(userPreferencesProvider.select((s) => s.themeMode)); final accentMaterialColor = @@ -195,6 +177,7 @@ class SpotubeState extends ConsumerState { () => theme(paletteColor ?? accentMaterialColor, Brightness.light, false), [paletteColor, accentMaterialColor], ); + final darkTheme = useMemoized( () => theme( paletteColor ?? accentMaterialColor, diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index 9c9bdddb..c9367e05 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -17,7 +17,7 @@ class DesktopLoginPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final mediaQuery = MediaQuery.of(context); final theme = Theme.of(context); - final color = theme.colorScheme.surfaceVariant.withOpacity(.3); + final color = theme.colorScheme.surfaceContainerHighest.withOpacity(.3); return SafeArea( child: Scaffold( diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 850eccfa..1d9b383a 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -100,7 +100,7 @@ class LyricsPage extends HookConsumerWidget { child: Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background.withOpacity(.4), + color: Theme.of(context).colorScheme.surface.withOpacity(.4), borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 996e190d..a026209c 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -107,8 +107,7 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.lyricsOff), style: ButtonStyle( foregroundColor: showLyrics.value - ? MaterialStateProperty.all( - theme.colorScheme.primary) + ? WidgetStateProperty.all(theme.colorScheme.primary) : null, ), onPressed: () async { @@ -132,8 +131,7 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.hoverOff), style: ButtonStyle( foregroundColor: hoverMode.value - ? MaterialStateProperty.all( - theme.colorScheme.primary) + ? WidgetStateProperty.all(theme.colorScheme.primary) : null, ), onPressed: () async { @@ -154,7 +152,7 @@ class MiniLyricsPage extends HookConsumerWidget { ), style: ButtonStyle( foregroundColor: snapshot.data == true - ? MaterialStateProperty.all( + ? WidgetStateProperty.all( theme.colorScheme.primary) : null, ), @@ -186,12 +184,12 @@ class MiniLyricsPage extends HookConsumerWidget { child: TabBarView( children: [ SyncedLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), + palette: PaletteColor(theme.colorScheme.surface, 0), isModal: true, defaultTextZoom: 65, ), PlainLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), + palette: PaletteColor(theme.colorScheme.surface, 0), isModal: true, defaultTextZoom: 65, ), diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index d5374786..50ef152b 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -212,7 +212,7 @@ class SearchPage extends HookConsumerWidget { Icon( SpotubeIcons.web, size: 120, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.7), ), const SizedBox(height: 20), @@ -220,7 +220,7 @@ class SearchPage extends HookConsumerWidget { context.l10n.search_to_get_results, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w900, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.5), ), ), @@ -246,7 +246,7 @@ class SearchPage extends HookConsumerWidget { style: TextStyle( fontSize: 20, fontWeight: FontWeight.w900, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.7), ), ), diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 6eccab07..4e937922 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -20,7 +20,6 @@ class BlackListPage extends HookConsumerWidget { final controller = useScrollController(); final blacklist = ref.watch(blacklistProvider); final searchText = useState(""); - final filteredBlacklist = useMemoized( () { if (searchText.value.isEmpty) { diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index a8d72cc0..5e5d2377 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -43,10 +43,9 @@ class SettingsAboutSection extends HookConsumerWidget { ), trailing: (context, update) => FilledButton( style: ButtonStyle( - backgroundColor: MaterialStatePropertyAll(Colors.red[100]), - foregroundColor: - const MaterialStatePropertyAll(Colors.pinkAccent), - padding: const MaterialStatePropertyAll(EdgeInsets.all(15)), + backgroundColor: WidgetStatePropertyAll(Colors.red[100]), + foregroundColor: const WidgetStatePropertyAll(Colors.pinkAccent), + padding: const WidgetStatePropertyAll(EdgeInsets.all(15)), ), onPressed: () { launchUrlString( diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 6162aa3d..5acab480 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -82,7 +82,7 @@ class SettingsAccountSection extends HookConsumerWidget { router.push("/login"); }, style: ButtonStyle( - shape: MaterialStateProperty.all( + shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(25.0), ), diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index 3092ed03..76ef8e3e 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -3,7 +3,6 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 51e98269..cf1da7be 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -4,13 +4,22 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { final scheme = ColorScheme.fromSeed( seedColor: seed, shadow: Colors.black12, - background: isAmoled ? Colors.black : null, surface: isAmoled ? Colors.black : null, + surfaceContainer: isAmoled ? const Color(0xFF090909) : null, + surfaceContainerHigh: isAmoled ? const Color(0xFF181818) : null, + surfaceContainerHighest: isAmoled ? const Color(0xFF282828) : null, brightness: brightness, ); return ThemeData( useMaterial3: true, colorScheme: scheme, + scaffoldBackgroundColor: isAmoled ? Colors.black : null, + cardTheme: CardTheme( + color: scheme.surfaceContainer, + shadowColor: scheme.shadow, + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + ), listTileTheme: ListTileThemeData( horizontalTitleGap: 5, iconColor: scheme.onSurface, @@ -25,7 +34,7 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { navigationBarTheme: const NavigationBarThemeData( labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, height: 50, - iconTheme: MaterialStatePropertyAll( + iconTheme: WidgetStatePropertyAll( IconThemeData(size: 18), ), ), @@ -52,25 +61,25 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { ), sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay), searchBarTheme: SearchBarThemeData( - textStyle: const MaterialStatePropertyAll(TextStyle(fontSize: 15)), + textStyle: const WidgetStatePropertyAll(TextStyle(fontSize: 15)), constraints: const BoxConstraints(maxWidth: double.infinity), - padding: const MaterialStatePropertyAll(EdgeInsets.all(8)), - backgroundColor: MaterialStatePropertyAll( + padding: const WidgetStatePropertyAll(EdgeInsets.all(8)), + backgroundColor: WidgetStatePropertyAll( Color.lerp( - scheme.surfaceVariant, + scheme.surfaceContainerHighest, scheme.surface, brightness == Brightness.light ? .9 : .7, ), ), - elevation: const MaterialStatePropertyAll(0), - shape: MaterialStatePropertyAll( + elevation: const WidgetStatePropertyAll(0), + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), ), scrollbarTheme: const ScrollbarThemeData( - thickness: MaterialStatePropertyAll(14), + thickness: WidgetStatePropertyAll(14), ), checkboxTheme: CheckboxThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), From c607a330ed279dfbebe8d4bd325745ac6301a58f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 2 Jun 2024 22:34:06 +0600 Subject: [PATCH 092/261] fix(playback): skipping tracks with unplayable sources instead of falling back #1492 --- lib/services/sourced_track/sourced_track.dart | 8 +------ .../sourced_track/sources/youtube.dart | 22 ++++++++++++------- lib/themes/theme.dart | 8 ++++++- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index a5e094ed..7eedfad8 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -135,16 +135,10 @@ abstract class SourcedTrack extends Track { return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); } catch (e) { if (e is DioException || e is ClientException || e is SocketException) { - if (preferences.audioSource == AudioSource.jiosaavn) { - return await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: true, - ); - } return await JioSaavnSourcedTrack.fetchFromTrack( track: track, ref: ref, + weakMatch: preferences.audioSource == AudioSource.jiosaavn, ); } rethrow; diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 3fc78f0b..c24edfc0 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -1,3 +1,4 @@ +import 'package:catcher_2/core/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart'; @@ -221,14 +222,19 @@ class YoutubeSourcedTrack extends SourcedTrack { final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube"); if (ytLink?.url != null) { - return [ - await toSiblingType( - 0, - YoutubeVideoInfo.fromVideo( - await youtubeClient.videos.get(ytLink!.url!), - ), - ) - ]; + try { + return [ + await toSiblingType( + 0, + YoutubeVideoInfo.fromVideo( + await youtubeClient.videos.get(ytLink!.url!), + ), + ) + ]; + } on VideoUnplayableException catch (e, stack) { + // Ignore this error and continue with the search + Catcher2.reportCheckedError(e, stack); + } } final query = SourcedTrack.getSearchTerm(track); diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index cf1da7be..390a7509 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -24,7 +24,13 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { horizontalTitleGap: 5, iconColor: scheme.onSurface, ), - appBarTheme: const AppBarTheme(surfaceTintColor: Colors.transparent), + appBarTheme: const AppBarTheme( + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + elevation: 0, + backgroundColor: Colors.transparent, + ), inputDecorationTheme: InputDecorationTheme( border: OutlineInputBorder( borderRadius: BorderRadius.circular(15), From e63a4bb63c33bf4291a91925e1ea12c1c1afde19 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 3 Jun 2024 10:09:41 +0600 Subject: [PATCH 093/261] chore: migrate android gradle to declarative config syntax --- .fvm/fvm_config.json | 2 +- .github/workflows/pr-lint.yml | 2 +- .github/workflows/spotube-release-binary.yml | 2 +- android/app/build.gradle | 30 ++++++++------------ android/build.gradle | 13 --------- android/settings.gradle | 30 ++++++++++++++------ lib/themes/theme.dart | 1 - 7 files changed, 37 insertions(+), 43 deletions(-) diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 6a56dfc6..df8efa0e 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.22.0", + "flutterSdkVersion": "3.22.1", "flavors": {} } \ No newline at end of file diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index 156d1a07..2844986d 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,7 +4,7 @@ on: pull_request: env: - FLUTTER_VERSION: '3.19.5' + FLUTTER_VERSION: '3.22.1' jobs: lint: diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index eb62b58d..8e68211c 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -20,7 +20,7 @@ on: description: Dry run without uploading to release env: - FLUTTER_VERSION: 3.22.0 + FLUTTER_VERSION: 3.22.1 permissions: contents: write diff --git a/android/app/build.gradle b/android/app/build.gradle index 2f85cdeb..7bcd9b6a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,10 +22,6 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { @@ -71,6 +68,9 @@ android { release { signingConfig signingConfigs.release } + debug { + signingConfig signingConfigs.release + } } flavorDimensions "default" @@ -81,16 +81,19 @@ android { resValue "string", "app_name_en", "Spotube Nightly" applicationIdSuffix ".nightly" versionNameSuffix "-nightly" + signingConfig signingConfigs.release } dev { dimension "default" resValue "string", "app_name_en", "Spotube Dev" applicationIdSuffix ".dev" versionNameSuffix "-dev" + signingConfig signingConfigs.release } stable { dimension "default" resValue "string", "app_name_en", "Spotube" + signingConfig signingConfigs.release } } @@ -101,15 +104,6 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - constraints { - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version") { - because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib") - } - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version") { - because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib") - } - } implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' // other deps so just ignore diff --git a/android/build.gradle b/android/build.gradle index 0801de62..bc157bd1 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.8.22' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.2.1' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() diff --git a/android/settings.gradle b/android/settings.gradle index 44e62bcf..89651748 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,11 +1,25 @@ -include ':app' +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.2.1" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" \ No newline at end of file diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 390a7509..28acc280 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -29,7 +29,6 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { scrolledUnderElevation: 0, shadowColor: Colors.transparent, elevation: 0, - backgroundColor: Colors.transparent, ), inputDecorationTheme: InputDecorationTheme( border: OutlineInputBorder( From bc534aa240c142dc2e4289b96318573579d14c43 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 3 Jun 2024 10:56:51 +0600 Subject: [PATCH 094/261] chore: disable impeller for now --- android/app/src/main/AndroidManifest.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 52547f04..589e22ff 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,9 +25,9 @@ android:requestLegacyExternalStorage="true" > - + android:value="true" /> --> Date: Mon, 3 Jun 2024 12:46:52 +0600 Subject: [PATCH 095/261] fix(windows): installer tries to install in current directory --- windows/packaging/exe/inno_setup.iss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/windows/packaging/exe/inno_setup.iss b/windows/packaging/exe/inno_setup.iss index 64acc2b3..dbb8082b 100644 --- a/windows/packaging/exe/inno_setup.iss +++ b/windows/packaging/exe/inno_setup.iss @@ -12,7 +12,7 @@ AppPublisher={{PUBLISHER_NAME}} AppPublisherURL={{PUBLISHER_URL}} AppSupportURL={{PUBLISHER_URL}} AppUpdatesURL={{PUBLISHER_URL}} -DefaultDirName={{INSTALL_DIR_NAME}} +DefaultDirName={autopf}\{{DISPLAY_NAME}} DisableProgramGroupPage=yes OutputDir=. OutputBaseFilename={{OUTPUT_BASE_FILENAME}} From f6ba95fb64986cda613d8cc79aa84841f0ed61f1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 3 Jun 2024 13:13:05 +0600 Subject: [PATCH 096/261] chore: upgrade deps and appbar bg fix --- lib/components/shared/page_window_title_bar.dart | 4 ++++ pubspec.lock | 8 ++++---- pubspec.yaml | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index 66709844..573c7c47 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -165,6 +165,10 @@ class _PageWindowTitleBarState extends ConsumerState { toolbarTextStyle: widget.toolbarTextStyle, titleTextStyle: widget.titleTextStyle, title: widget.title, + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + forceMaterialTransparency: true, + elevation: 0, ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 32da1f8d..cf72db1c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2418,10 +2418,10 @@ packages: dependency: "direct main" description: name: window_manager - sha256: b3c895bdf936c77b83c5254bec2e6b3f066710c1f89c38b20b8acc382b525494 + sha256: "8699323b30da4cdbe2aa2e7c9de567a6abd8a97d9a5c850a3c86dcd0b34bbfbf" url: "https://pub.dev" source: hosted - version: "0.3.8" + version: "0.3.9" window_size: dependency: "direct main" description: @@ -2459,10 +2459,10 @@ packages: dependency: "direct main" description: name: youtube_explode_dart - sha256: "12d32dffd8c85927eb46f7cf7a9dfce690edfe82134c08a90529c51eba58a85c" + sha256: "26c9671d638f3396a1bfb2666f586988ee7b0ba3469e478b22a4c1a168bcf6ee" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" sdks: dart: ">=3.3.0 <4.0.0" flutter: ">=3.19.2" diff --git a/pubspec.yaml b/pubspec.yaml index 56c25dd7..80e930fe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -86,13 +86,13 @@ dependencies: uuid: ^4.4.0 version: ^3.0.2 visibility_detector: ^0.4.0+2 - window_manager: ^0.3.8 + window_manager: ^0.3.9 window_size: git: url: https://github.com/google/flutter-desktop-embedding.git ref: a738913c8ce2c9f47515382d40827e794a334274 path: plugins/window_size - youtube_explode_dart: ^2.2.0 + youtube_explode_dart: ^2.2.1 simple_icons: ^10.1.3 jiosaavn: ^0.1.0 draggable_scrollbar: From 9cd44b6c9ba0f69eb2f7c544e578743ecc778500 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 3 Jun 2024 13:15:10 +0600 Subject: [PATCH 097/261] chore: podspec update --- ios/Podfile.lock | 43 ++++++++--------- ios/Runner.xcodeproj/project.pbxproj | 72 ++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 24 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1d048cc9..f8533902 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -69,9 +69,6 @@ PODS: - fluttertoast (0.0.2): - Flutter - Toast - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - image_picker_ios (0.0.1): - Flutter - integration_test (0.0.1): @@ -87,7 +84,7 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - permission_handler_apple (9.1.1): + - permission_handler_apple (9.3.0): - Flutter - SDWebImage (5.18.8): - SDWebImage/Core (= 5.18.8) @@ -97,7 +94,7 @@ PODS: - FlutterMacOS - sqflite (0.0.3): - Flutter - - FMDB (>= 2.7.5) + - FlutterMacOS - SwiftyGif (5.4.4) - Toast (4.0.0) - url_launcher_ios (0.0.1): @@ -129,14 +126,13 @@ DEPENDENCIES: - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `.symlinks/plugins/sqflite/ios`) + - sqflite (from `.symlinks/plugins/sqflite/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery - - FMDB - OrderedSet - SDWebImage - SwiftyGif @@ -194,45 +190,44 @@ EXTERNAL SOURCES: shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite: - :path: ".symlinks/plugins/sqflite/ios" + :path: ".symlinks/plugins/sqflite/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 + app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795 audio_service: f509d65da41b9521a61f1c404dd58651f265a567 - audio_session: 4f3e461722055d21515cf3261b64c973c062f345 + audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 - device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de - file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + file_selector_ios: 78baf21d03f1e37a7df97bb2494f9cd86de8fa5d Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 - flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef + flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 - fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 - integration_test: 13825b8a9334a850581300559b8839134b124670 + fluttertoast: 9f2f8e81bb5ce18facb9748d7855bf5a756fe3db + image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18 + integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3 media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c - package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 - permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 + url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 13f624a4..34793f68 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -324,6 +324,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */, + 46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -346,6 +347,7 @@ B536BD992B405DB1009B3CE4 /* Embed Frameworks */, B536BD9A2B405DB1009B3CE4 /* Thin Binary */, A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */, + 2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -368,6 +370,7 @@ B536BDB62B405FDE009B3CE4 /* Embed Frameworks */, B536BDB72B405FDE009B3CE4 /* Thin Binary */, 244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */, + 4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -390,6 +393,7 @@ B536BDD82B4060B3009B3CE4 /* Embed Frameworks */, B536BDD92B4060B3009B3CE4 /* Thin Binary */, D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */, + 5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -523,6 +527,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -539,6 +560,57 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 5CD4405E93760FBD048E36E2 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; From ab713a4eacf849a907e87d47cb5f479a765cba7c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 3 Jun 2024 13:44:16 +0600 Subject: [PATCH 098/261] chore: bump version and generate changelogs --- .github/workflows/spotube-publish-binary.yml | 2 +- CHANGELOG.md | 34 +++++++++++++++++++- pubspec.yaml | 2 +- windows/runner/Runner.rc | 2 +- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 960507f9..0d39ab1d 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.6.0 + default: 3.7.0 required: true dry_run: description: Dry run diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ca4b69..21fb79d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,39 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. -## [3.6.0-0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0-0) (2024-04-15) +## [3.7.0](https://github.com/krtirtho/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) + + +### Features + +* local library folder cards ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) +* Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) +* personalized stats based on local music history ([#1522](https://github.com/krtirtho/spotube/issues/1522)) ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) +* play initially available tracks of playlist/album immediately and fetch rest in background [#670](https://github.com/krtirtho/spotube/issues/670) ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) +* **player:** add volume slider floating label showing percentage ([#1445](https://github.com/krtirtho/spotube/issues/1445)) ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +* **translations:** add Basque translation ([#1493](https://github.com/krtirtho/spotube/issues/1493)) ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) +* **translations:** add Finnish translations ([#1449](https://github.com/krtirtho/spotube/issues/1449)) ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +* **translations:** add georgian language ([#1450](https://github.com/krtirtho/spotube/issues/1450)) ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) +* **translations:** add Indonesian translation ([#1426](https://github.com/krtirtho/spotube/issues/1426)) ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +* **translations:** Improve tr locales ([#1419](https://github.com/krtirtho/spotube/issues/1419)) ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +* upgrade to Flutter 3.22.0 ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) + + +### Bug Fixes + +* fallback to LRCLIB when lyrics line less than 6 lines [#1461](https://github.com/krtirtho/spotube/issues/1461) ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) +* **linux:** tray icon not showing [#541](https://github.com/krtirtho/spotube/issues/541) ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) +* local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) +* **macos:** Logs directory not created by default [#1353](https://github.com/krtirtho/spotube/issues/1353) ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) +* **playback:** skipping tracks with unplayable sources instead of falling back [#1492](https://github.com/krtirtho/spotube/issues/1492) ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) +* **search:** load more button not working [#1417](https://github.com/krtirtho/spotube/issues/1417) ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) +* some text are garbled in different parts of the app [#1463](https://github.com/krtirtho/spotube/issues/1463) [#1505](https://github.com/krtirtho/spotube/issues/1505) ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) +* spotify friends and user profile icon (mobile) showing when not authenticated [#1410](https://github.com/krtirtho/spotube/issues/1410) ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) +* **updater:** dead link ([#1408](https://github.com/krtirtho/spotube/issues/1408)) ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +* windows SSL Certificate error breaking login [#905](https://github.com/krtirtho/spotube/issues/905) ([#1474](https://github.com/krtirtho/spotube/issues/1474)) ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), closes [#1468](https://github.com/krtirtho/spotube/issues/1468) +* **windows:** installer tries to install in current directory ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) + +## [3.6.0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) ### Features diff --git a/pubspec.yaml b/pubspec.yaml index 80e930fe..c256f66e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.6.0+30 +version: 3.7.0+31 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 0b586d33..27632667 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -69,7 +69,7 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #if defined(FLUTTER_VERSION) #define VERSION_AS_STRING FLUTTER_VERSION #else -#define VERSION_AS_STRING "3.6.0" +#define VERSION_AS_STRING "3.7.0" #endif VS_VERSION_INFO VERSIONINFO From 3aca7372af8ae1b62a2ad657341331a5d310d678 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 3 Jun 2024 13:45:04 +0600 Subject: [PATCH 099/261] chore: Release v3.7.0 (#1552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: fix analyzer issues * fix(updater): dead link (#1408) * docs: broken link in README.md (fixes #1310) (#1311) * docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. * Update use_update_checker.dart --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho * fix(linux): tray icon not showing #541 upgrade old packages * fix(search): load more button not working #1417 * fix: spotify friends and user profile icon (mobile) showing when not authenticated #1410 * chore: add docker and m1 based linux arm build * cd: fix sed failing us * cd: use docker cask * fix: windows SSL Certificate error breaking login #905 (#1474) * fix: certificate error by using custom ssl certificate * Cd/docker linux ar (#1468) * cd: use docker buildx * cd: use linux host for linux arm instead of macos m1 m1 doesn't support nested virtualization. (Apple truly sucks) * cd: don't specify arch in Dockerfile * cd: use custom Dockerfile from ubuntu instead of flutter image * cd: add setup java for android * cd: add flutter distributor pre-built docker image for arm * cd: save me from this cursed arm build * cd: ?? * cd: ?? * cd: use docker build * fix: windows SSL Exception for Signing in * refactor: extract update checker as a basic function instead of a hook * cd: fix windows build error due to nightly version format * cd: fix github versioning scheme * chore: remove assets/ca entry in pubspec.yaml * fix(macos): Logs directory not created by default #1353 * refactor: Dart based Github Workflow CLI (#1490) * feat: add build dart script for windows * feat: add android build support * feat: add linux build support * feat: add macos build support * feat: add ios build support * feat: add deps install command and workflow file * cd: what? * cd: what? * cd: what? * cd: update workflow inputs * cd: replace release binary * cd: run flutter pub get * cd: use dpkg zstd instead of xz, windows disable innoInstall, fix channel enum.name and reset pubspec after changing build no for nightly * cd: fix tar copy path * cd: fix copy linux command * cd: fix windows inno depend and fix android aab path * cd: idk * cd: linux why??? * cd: windows choco copy failed * cd: use dart tar archive for creating tar file * cd: fix linux file copy error * cd: use tar command directly * feat: add linux_arm platform * cd: add linux_arm platform * cd: don't know what? * feat: notification about nightly channel update * chore: fix some errors parsing nightly version info * refactor: move dart scripts as commands under CLI * chore: add translated message command to command list * feat(translations): add Basque translation (#1493) * added Basque translation * chore: fix country codes and language native name --------- Co-authored-by: Kingkor Roy Tirtho * feat(translations): add georgian language (#1450) * feat: add georgian language * feat: translate more georgian words * feat(translations): add Finnish translations (#1449) * docs: broken link in README.md (fixes #1310) (#1311) * docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. * added finnish translation * chore: fix arb syntax errors and language in l10n entries --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho Co-authored-by: Onni Nevala * feat(translations): add Indonesian translation (#1426) * docs: broken link in README.md (fixes #1310) (#1311) * docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. * Add Indonesia translation --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho * feat(translations): Improve tr locales (#1419) * docs: broken link in README.md (fixes #1310) (#1311) * docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. * Improve tr locales --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho * feat(player): add volume slider floating label showing percentage (#1445) * docs: broken link in README.md (fixes #1310) (#1311) * docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. * add volume level tooltip in volume_slider --------- Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Kingkor Roy Tirtho * fix: fallback to LRCLIB when lyrics line less than 6 lines #1461 * feat: Local music library (#1479) * feat: add one additional library folder This folder just doesn't get downloaded to. I think I'm going to rework it so that it can be multiple folders, but I'm going to commit my progress so far anyway. Signed-off-by: Blake Leonard * chore: update dependencies so that it builds I'm not sure if this breaks CI or something, but I couldn't build it locally to test my changes, so I made these changes and it builds again. Signed-off-by: Blake Leonard * feat: index multiple folders of local music If you used a previous commit from this branch, this is a breaking change, because it changes the type of a configuration field. but since this is still in development, it should be fine. Signed-off-by: Blake Leonard * refactor: manage local library in local tracks tab This also refactors the list to use slivers instead. That's the easiest way to have multiple scrolling lists here... The console keeps getting spammed with some intermediate layout error but I can't hold it long enough to figure out what's causing it. Signed-off-by: Blake Leonard * refactor: use folder add/remove icons in library Signed-off-by: Blake Leonard * refactor: remove redundant settings page Signed-off-by: Blake Leonard * refactor: rename "Local Tracks" to just "Local" Not sure if this would be the recommended way to do it... Signed-off-by: Blake Leonard * fix: console spam about useless Expanded Signed-off-by: Blake Leonard * chore: remove completed TODO Signed-off-by: Blake Leonard * chore: use new Platform constants; regenerate plugins Signed-off-by: Blake Leonard * refactor: put local libraries on separate pages Signed-off-by: Blake Leonard --------- Signed-off-by: Blake Leonard * fix: local track not showing up in queue * feat: local library folder cards * feat: personalized stats based on local music history (#1522) * feat: add playback history provider * feat: implement recently played section * refactor: use route names * feat: add stats summary and top tracks/artists/albums * feat: add top date based filtering * feat: add stream money calculation * refactor: place search in mobile navbar and settings in home appbar * feat: add individual minutes and streams page * feat(stats): add individual minutes and streams page * chore: default period to 1 month * feat: add text to explain user how hypothetical fees are calculated * chore: ensure usage of route names instead of direct paths * cd: add cache key * cd: remove media_kit_event_loop from git * fix: some text are garbled in different parts of the app #1463 #1505 * refactor: use replace http with dio and use it as the default * cd: use dio in cli as well * chore: fix home feed not showing up * chore: downloaded tracks folder not opening * feat: play initially available tracks of playlist/album immediately and fetch rest in background #670 * feat: upgrade to Flutter 3.22.0 * refactor: migrate deprecated warnings * fix(playback): skipping tracks with unplayable sources instead of falling back #1492 * chore: migrate android gradle to declarative config syntax * chore: disable impeller for now * fix(windows): installer tries to install in current directory * chore: upgrade deps and appbar bg fix * chore: podspec update * chore: bump version and generate changelogs --------- Signed-off-by: Blake Leonard Co-authored-by: Kshamendra Co-authored-by: MerkomassDev <70111455+MerkomassDev@users.noreply.github.com> Co-authored-by: Karim <37943746+ksaadDE@users.noreply.github.com> Co-authored-by: Josu Igoa Co-authored-by: Omari Sopromadze Co-authored-by: ctih <78687256+ctih1@users.noreply.github.com> Co-authored-by: Onni Nevala Co-authored-by: Yusril Rapsanjani Co-authored-by: W͏ I͏ N͏ Z͏ O͏ R͏ T͏ <75412448+mikropsoft@users.noreply.github.com> Co-authored-by: Akash Pattnaik Co-authored-by: Blake Leonard --- .dockerignore | 6 + .env.example | 3 + .fvm/fvm_config.json | 2 +- .github/Dockerfile | 23 + .github/Dockerfile.flutter_distributor | 23 + .github/workflows/pr-lint.yml | 2 +- .github/workflows/spotube-publish-binary.yml | 2 +- .github/workflows/spotube-release-binary.yml | 446 ++--------- .metadata | 16 +- .vscode/settings.json | 1 + CHANGELOG.md | 34 +- android/app/build.gradle | 30 +- android/app/src/main/AndroidManifest.xml | 5 + android/build.gradle | 13 - android/settings.gradle | 30 +- bin/gen-credits.dart | 103 --- bin/translated_messages.dart | 28 - bin/untranslated_messages.dart | 50 -- bin/verify-pkgbuild.dart | 22 - build.yaml | 7 +- cli/README.md | 4 + cli/cli.dart | 22 + cli/commands/build.dart | 25 + cli/commands/build/android.dart | 90 +++ cli/commands/build/common.dart | 66 ++ cli/commands/build/ios.dart | 29 + cli/commands/build/linux.dart | 106 +++ cli/commands/build/linux_arm.dart | 37 + cli/commands/build/macos.dart | 42 + cli/commands/build/windows.dart | 100 +++ cli/commands/credits.dart | 121 +++ cli/commands/install-dependencies.dart | 74 ++ cli/commands/translated.dart | 39 + cli/commands/untranslated.dart | 48 ++ cli/core/env.dart | 24 + devtools_options.yaml | 1 + ios/Podfile.lock | 43 +- ios/Runner.xcodeproj/project.pbxproj | 72 ++ lib/collections/env.dart | 18 +- lib/collections/fake.dart | 1 - lib/collections/formatters.dart | 8 + lib/collections/initializers.dart | 5 +- lib/collections/intents.dart | 12 +- lib/collections/language_codes.dart | 32 +- lib/collections/routes.dart | 122 ++- lib/collections/side_bar_tiles.dart | 77 +- lib/collections/spotube_icons.dart | 3 + lib/components/album/album_card.dart | 18 +- lib/components/artist/artist_card.dart | 21 +- lib/components/connect/connect_device.dart | 7 +- lib/components/desktop_login/login_form.dart | 3 +- lib/components/home/sections/feed.dart | 10 +- lib/components/home/sections/friends.dart | 47 +- .../home/sections/friends/friend_item.dart | 24 +- lib/components/home/sections/genres.dart | 16 +- lib/components/home/sections/recent.dart | 32 + .../local_folder/local_folder_item.dart | 199 +++++ .../playlist_generate/multi_select_field.dart | 2 +- lib/components/library/user_local_tracks.dart | 368 ++------- lib/components/player/player_queue.dart | 3 +- .../player/sibling_tracks_sheet.dart | 3 +- lib/components/player/volume_slider.dart | 17 +- lib/components/playlist/playlist_card.dart | 57 +- lib/components/root/bottom_player.dart | 29 +- lib/components/root/sidebar.dart | 101 ++- .../root/spotube_navigation_bar.dart | 42 +- lib/components/root/update_dialog.dart | 56 ++ .../settings/color_scheme_picker_dialog.dart | 4 +- .../adaptive/adaptive_pop_sheet_list.dart | 2 +- .../shared/fallbacks/anonymous_fallback.dart | 3 +- .../horizontal_playbutton_card_view.dart | 2 +- .../inter_scrollbar/inter_scrollbar.dart | 4 +- .../shared/links/anchor_button.dart | 2 +- lib/components/shared/links/artist_link.dart | 8 +- .../shared/page_window_title_bar.dart | 51 +- lib/components/shared/playbutton_card.dart | 14 +- .../shared/themed_button_tab_bar.dart | 6 +- .../shared/track_tile/track_options.dart | 224 +++--- .../shared/track_tile/track_tile.dart | 23 +- .../sections/body/track_view_body.dart | 25 +- .../sections/body/track_view_options.dart | 15 + .../sections/header/flexible_header.dart | 5 +- .../sections/header/header_actions.dart | 10 + .../sections/header/header_buttons.dart | 65 +- .../shared/tracks_view/track_view.dart | 5 +- .../shared/tracks_view/track_view_props.dart | 12 +- lib/components/shared/waypoint.dart | 8 +- lib/components/stats/common/album_item.dart | 53 ++ lib/components/stats/common/artist_item.dart | 39 + .../stats/common/playlist_item.dart | 46 ++ lib/components/stats/common/track_item.dart | 49 ++ lib/components/stats/summary/summary.dart | 100 +++ .../stats/summary/summary_card.dart | 86 +++ lib/components/stats/top/albums.dart | 29 + lib/components/stats/top/artists.dart | 27 + lib/components/stats/top/top.dart | 106 +++ lib/components/stats/top/tracks.dart | 31 + lib/extensions/album_simple.dart | 15 - lib/extensions/artist_simple.dart | 12 - lib/extensions/track.dart | 29 - .../configurators/use_close_behavior.dart | 26 +- lib/hooks/configurators/use_deep_linking.dart | 4 +- .../use_disable_battery_optimizations.dart | 6 +- .../configurators/use_get_storage_perms.dart | 13 +- .../configurators/use_init_sys_tray.dart | 128 --- .../configurators/use_update_checker.dart | 100 --- .../configurators/use_window_listener.dart | 10 +- lib/hooks/utils/use_palette_color.dart | 7 +- lib/l10n/app_en.arb | 7 +- lib/l10n/app_eu.arb | 324 ++++++++ lib/l10n/app_fi.arb | 324 ++++++++ lib/l10n/app_id.arb | 324 ++++++++ lib/l10n/app_ka.arb | 324 ++++++++ lib/l10n/app_tr.arb | 196 ++--- lib/l10n/l10n.dart | 6 +- lib/main.dart | 75 +- lib/models/connect/connect.dart | 1 - lib/models/connect/connect.freezed.dart | 500 ++++++++++-- lib/models/connect/connect.g.dart | 52 +- lib/models/connect/load.dart | 19 +- lib/models/current_playlist.dart | 1 - lib/models/local_track.dart | 4 +- lib/models/logger.dart | 2 +- lib/models/source_match.g.dart | 2 +- lib/models/spotify/home_feed.freezed.dart | 2 +- lib/models/spotify/home_feed.g.dart | 70 +- .../spotify/recommendation_seeds.freezed.dart | 2 +- .../spotify/recommendation_seeds.g.dart | 3 +- lib/models/spotify_friends.g.dart | 39 +- lib/pages/album/album.dart | 4 +- lib/pages/artist/artist.dart | 2 + lib/pages/artist/section/top_tracks.dart | 3 +- lib/pages/connect/connect.dart | 7 +- lib/pages/connect/control/control.dart | 11 +- lib/pages/desktop_login/desktop_login.dart | 4 +- lib/pages/desktop_login/login_tutorial.dart | 4 +- .../getting_started/getting_started.dart | 2 + .../getting_started/sections/support.dart | 6 +- lib/pages/home/feed/feed_section.dart | 2 + lib/pages/home/genres/genre_playlists.dart | 10 +- lib/pages/home/genres/genres.dart | 10 +- lib/pages/home/home.dart | 37 +- lib/pages/lastfm_login/lastfm_login.dart | 1 + lib/pages/library/library.dart | 4 +- lib/pages/library/local_folder.dart | 240 ++++++ .../playlist_generate/playlist_generate.dart | 2 + .../playlist_generate_result.dart | 10 +- lib/pages/lyrics/lyrics.dart | 4 +- lib/pages/lyrics/mini_lyrics.dart | 104 +-- lib/pages/mobile_login/mobile_login.dart | 1 + lib/pages/playlist/liked_playlist.dart | 5 +- lib/pages/playlist/playlist.dart | 4 +- lib/pages/profile/profile.dart | 2 + lib/pages/root/root_app.dart | 48 +- lib/pages/search/search.dart | 197 ++--- lib/pages/search/sections/tracks.dart | 4 +- lib/pages/settings/about.dart | 10 + lib/pages/settings/blacklist.dart | 3 +- lib/pages/settings/logs.dart | 2 + lib/pages/settings/sections/about.dart | 7 +- lib/pages/settings/sections/accounts.dart | 30 +- lib/pages/settings/sections/desktop.dart | 4 +- lib/pages/settings/sections/downloads.dart | 4 +- lib/pages/settings/settings.dart | 8 +- lib/pages/stats/albums/albums.dart | 38 + lib/pages/stats/artists/artists.dart | 38 + lib/pages/stats/fees/fees.dart | 65 ++ lib/pages/stats/minutes/minutes.dart | 44 ++ lib/pages/stats/playlists/playlists.dart | 39 + lib/pages/stats/stats.dart | 35 + lib/pages/stats/streams/streams.dart | 44 ++ lib/pages/track/track.dart | 2 + lib/provider/authentication_provider.dart | 43 +- lib/provider/connect/server.dart | 15 +- lib/provider/discord_provider.dart | 6 +- lib/provider/history/history.dart | 129 ++++ lib/provider/history/recent.dart | 40 + lib/provider/history/state.dart | 35 + lib/provider/history/state.freezed.dart | 644 ++++++++++++++++ lib/provider/history/state.g.dart | 55 ++ lib/provider/history/summary.dart | 62 ++ lib/provider/history/top.dart | 95 +++ .../local_tracks/local_tracks_provider.dart | 125 +++ .../proxy_playlist/player_listeners.dart | 57 +- .../proxy_playlist/proxy_playlist.dart | 18 +- .../proxy_playlist_provider.dart | 28 +- .../proxy_playlist/skip_segments.dart | 49 +- lib/provider/spotify/lyrics/synced.dart | 48 +- lib/provider/spotify/spotify.dart | 4 +- lib/provider/tray_manager/tray_manager.dart | 79 ++ lib/provider/tray_manager/tray_menu.dart | 108 +++ .../user_preferences_provider.dart | 16 +- .../user_preferences_state.dart | 5 +- .../user_preferences_state.freezed.dart | 41 +- .../user_preferences_state.g.dart | 12 +- lib/services/audio_player/audio_player.dart | 14 +- lib/services/audio_player/custom_player.dart | 6 +- .../audio_services/audio_services.dart | 10 +- .../audio_services/mobile_audio_service.dart | 4 +- .../spotify_endpoints.dart | 90 +-- lib/services/dio/dio.dart | 3 + lib/services/kv_store/kv_store.dart | 20 + lib/services/song_link/song_link.freezed.dart | 2 +- lib/services/song_link/song_link.g.dart | 3 +- .../sourced_track/models/source_info.g.dart | 2 +- .../sourced_track/models/source_map.g.dart | 15 +- lib/services/sourced_track/sourced_track.dart | 8 +- lib/services/sourced_track/sources/piped.dart | 2 +- .../sourced_track/sources/youtube.dart | 22 +- lib/services/wm_tools/wm_tools.dart | 88 +++ lib/themes/theme.dart | 34 +- lib/utils/service_utils.dart | 157 +++- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 +- macos/Podfile.lock | 31 +- pubspec.lock | 728 ++++++++---------- pubspec.yaml | 120 +-- untranslated_messages.json | 210 ++++- windows/CMakeLists.txt | 29 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + windows/packaging/exe/inno_setup.iss | 2 +- windows/runner/Runner.rc | 14 +- 224 files changed, 8308 insertions(+), 2937 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/Dockerfile create mode 100644 .github/Dockerfile.flutter_distributor delete mode 100644 bin/gen-credits.dart delete mode 100644 bin/translated_messages.dart delete mode 100644 bin/untranslated_messages.dart delete mode 100644 bin/verify-pkgbuild.dart create mode 100644 cli/README.md create mode 100644 cli/cli.dart create mode 100644 cli/commands/build.dart create mode 100644 cli/commands/build/android.dart create mode 100644 cli/commands/build/common.dart create mode 100644 cli/commands/build/ios.dart create mode 100644 cli/commands/build/linux.dart create mode 100644 cli/commands/build/linux_arm.dart create mode 100644 cli/commands/build/macos.dart create mode 100644 cli/commands/build/windows.dart create mode 100644 cli/commands/credits.dart create mode 100644 cli/commands/install-dependencies.dart create mode 100644 cli/commands/translated.dart create mode 100644 cli/commands/untranslated.dart create mode 100644 cli/core/env.dart create mode 100644 devtools_options.yaml create mode 100644 lib/collections/formatters.dart create mode 100644 lib/components/home/sections/recent.dart create mode 100644 lib/components/library/local_folder/local_folder_item.dart create mode 100644 lib/components/root/update_dialog.dart create mode 100644 lib/components/stats/common/album_item.dart create mode 100644 lib/components/stats/common/artist_item.dart create mode 100644 lib/components/stats/common/playlist_item.dart create mode 100644 lib/components/stats/common/track_item.dart create mode 100644 lib/components/stats/summary/summary.dart create mode 100644 lib/components/stats/summary/summary_card.dart create mode 100644 lib/components/stats/top/albums.dart create mode 100644 lib/components/stats/top/artists.dart create mode 100644 lib/components/stats/top/top.dart create mode 100644 lib/components/stats/top/tracks.dart delete mode 100644 lib/hooks/configurators/use_init_sys_tray.dart delete mode 100644 lib/hooks/configurators/use_update_checker.dart create mode 100644 lib/l10n/app_eu.arb create mode 100644 lib/l10n/app_fi.arb create mode 100644 lib/l10n/app_id.arb create mode 100644 lib/l10n/app_ka.arb create mode 100644 lib/pages/library/local_folder.dart create mode 100644 lib/pages/stats/albums/albums.dart create mode 100644 lib/pages/stats/artists/artists.dart create mode 100644 lib/pages/stats/fees/fees.dart create mode 100644 lib/pages/stats/minutes/minutes.dart create mode 100644 lib/pages/stats/playlists/playlists.dart create mode 100644 lib/pages/stats/stats.dart create mode 100644 lib/pages/stats/streams/streams.dart create mode 100644 lib/provider/history/history.dart create mode 100644 lib/provider/history/recent.dart create mode 100644 lib/provider/history/state.dart create mode 100644 lib/provider/history/state.freezed.dart create mode 100644 lib/provider/history/state.g.dart create mode 100644 lib/provider/history/summary.dart create mode 100644 lib/provider/history/top.dart create mode 100644 lib/provider/local_tracks/local_tracks_provider.dart create mode 100644 lib/provider/tray_manager/tray_manager.dart create mode 100644 lib/provider/tray_manager/tray_menu.dart create mode 100644 lib/services/dio/dio.dart create mode 100644 lib/services/wm_tools/wm_tools.dart diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..ddfd1517 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +build +dist +.dart_tool +.idea +.github +.git \ No newline at end of file diff --git a/.env.example b/.env.example index 22abd24b..56665663 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,6 @@ ENABLE_UPDATE_CHECK= LASTFM_API_KEY= LASTFM_API_SECRET= + +# Release channel. Can be: nightly, stable +RELEASE_CHANNEL= diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 7ca74200..df8efa0e 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.19.1", + "flutterSdkVersion": "3.22.1", "flavors": {} } \ No newline at end of file diff --git a/.github/Dockerfile b/.github/Dockerfile new file mode 100644 index 00000000..2e393449 --- /dev/null +++ b/.github/Dockerfile @@ -0,0 +1,23 @@ +ARG FLUTTER_VERSION + +FROM --platform=linux/arm64 krtirtho/flutter_distributor_arm64:${FLUTTER_VERSION} + +ARG BUILD_VERSION + +WORKDIR /app + +COPY . . + +RUN chown -R $(whoami) /app + +RUN flutter pub get + +RUN alias dpkg-deb="dpkg-deb --Zxz" &&\ + flutter_distributor package --platform=linux --targets=deb --skip-clean + +RUN make tar VERSION=${BUILD_VERSION} ARCH=arm64 PKG_ARCH=aarch64 + +RUN mv build/spotube-linux-*-aarch64.tar.xz dist/ &&\ + mv dist/**/spotube-*-linux.deb dist/Spotube-linux-aarch64.deb + +CMD [ "sleep", "5000000" ] \ No newline at end of file diff --git a/.github/Dockerfile.flutter_distributor b/.github/Dockerfile.flutter_distributor new file mode 100644 index 00000000..952b9158 --- /dev/null +++ b/.github/Dockerfile.flutter_distributor @@ -0,0 +1,23 @@ +FROM --platform=linux/arm64 ubuntu:22.04 + +ARG FLUTTER_VERSION + +RUN apt-get clean &&\ + apt-get update &&\ + apt-get install -y bash curl file git unzip xz-utils zip libglu1-mesa cmake tar clang ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev rpm && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /home/flutter + +RUN git clone https://github.com/flutter/flutter.git -b ${FLUTTER_VERSION} --single-branch flutter-sdk + +RUN flutter-sdk/bin/flutter precache + +RUN flutter-sdk/bin/flutter config --no-analytics + +ENV PATH="$PATH:/home/flutter/flutter-sdk/bin" +ENV PATH="$PATH:/home/flutter/flutter-sdk/bin/cache/dart-sdk/bin" +ENV PATH="$PATH:/home/flutter/.pub-cache/bin" +ENV PUB_CACHE="/home/flutter/.pub-cache" + +RUN dart pub global activate flutter_distributor \ No newline at end of file diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index 156d1a07..2844986d 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,7 +4,7 @@ on: pull_request: env: - FLUTTER_VERSION: '3.19.5' + FLUTTER_VERSION: '3.22.1' jobs: lint: diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 805a89ac..0d39ab1d 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.1.0 + default: 3.7.0 required: true dry_run: description: Dry run diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index d9fbd0c7..8e68211c 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -2,279 +2,109 @@ name: Spotube Release Binary on: workflow_dispatch: inputs: - version: - description: Version to release (x.x.x) - default: 3.6.0 - required: true channel: type: choice - description: Release Channel - required: true options: - stable - nightly default: nightly + description: The release channel debug: - description: Debug on failed when channel is nightly - required: true type: boolean default: false + description: Debug with SSH toggle + required: false dry_run: - description: Dry run - required: true type: boolean - default: true + default: false + description: Dry run without uploading to release env: - FLUTTER_VERSION: '3.19.1' + FLUTTER_VERSION: 3.22.1 + +permissions: + contents: write jobs: - windows: - runs-on: windows-latest + build_platform: + strategy: + matrix: + include: + - os: ubuntu-latest + platform: linux + files: | + dist/Spotube-linux-x86_64.deb + dist/Spotube-linux-x86_64.rpm + dist/spotube-linux-*-x86_64.tar.xz + - os: ubuntu-latest + platform: linux_arm + files: | + dist/Spotube-linux-aarch64.deb + dist/spotube-linux-*-aarch64.tar.xz + - os: ubuntu-latest + platform: android + files: | + build/Spotube-android-all-arch.apk + build/Spotube-playstore-all-arch.aab + - os: windows-latest + platform: windows + files: | + dist/Spotube-windows-x86_64.nupkg + dist/Spotube-windows-x86_64-setup.exe + - os: macos-latest + platform: ios + files: | + Spotube-iOS.ipa + - os: macos-14 + platform: macos + files: | + build/Spotube-macos-universal.dmg + build/Spotube-macos-universal.pkg + runs-on: ${{matrix.os}} steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.12.0 with: cache: true + cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }} flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - choco install sed make yq -y - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $env:GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - "BUILD_VERSION=${{ inputs.version }}" >> $env:GITHUB_ENV - - - name: Replace version in files - run: | - choco install sed make -y - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" windows/runner/Runner.rc - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" choco-struct/tools/VERIFICATION.txt - sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" choco-struct/spotube.nuspec - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generating Secrets - run: | - flutter config --enable-windows-desktop - flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - - name: Build Windows Executable - run: | - dart pub global activate flutter_distributor - make innoinstall - 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 - if: ${{ inputs.channel == 'stable' }} - run: | - Set-Variable -Name HASH -Value (Get-FileHash dist\Spotube-windows-x86_64-setup.exe).Hash - sed -i "s/%{{WIN_SHA256}}%/$HASH/" choco-struct/tools/VERIFICATION.txt - make choco - mv dist/spotube.*.nupkg dist/Spotube-windows-x86_64.nupkg - - - - name: Upload Artifact - uses: actions/upload-artifact@v3 + - name: Setup Java + if: ${{matrix.platform == 'android'}} + uses: actions/setup-java@v4 with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-windows-x86_64.nupkg - dist/Spotube-windows-x86_64-setup.exe + distribution: 'zulu' + java-version: '17' + cache: 'gradle' + check-latest: true + - name: Set up QEMU + if: ${{matrix.platform == 'linux_arm'}} + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + if: ${{matrix.platform == 'linux_arm'}} + uses: docker/setup-buildx-action@v3 - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - linux: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Get current date - id: date - run: echo "::set-output name=date::$(date +'%Y-%m-%d')" - - - name: Install Dependencies - run: | - sudo apt-get update -y - sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev - - - name: Install AppImage Tool - run: | - wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" - chmod +x appimagetool - mv appimagetool /usr/local/bin/ - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - curl -sS https://webi.sh/yq | sh - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Replace Version in files - run: | - sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml - - - name: Generate Secrets - run: | - flutter config --enable-linux-desktop - flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - - name: Build Linux Packages - run: | - 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=rpm - - - name: Create tar.xz (stable) - if: ${{ inputs.channel == 'stable' }} - run: make tar VERSION=${{ env.BUILD_VERSION }} ARCH=x64 PKG_ARCH=x86_64 - - - name: Create tar.xz (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: make tar VERSION=nightly ARCH=x64 PKG_ARCH=x86_64 - - - name: Move Files to dist - run: | - 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 - - - - uses: actions/upload-artifact@v3 - if: ${{ inputs.channel == 'stable' }} - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-linux-x86_64.deb - dist/Spotube-linux-x86_64.rpm - dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz - - - uses: actions/upload-artifact@v3 - if: ${{ inputs.channel == 'nightly' }} - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - dist/Spotube-linux-x86_64.deb - dist/Spotube-linux-x86_64.rpm - dist/spotube-linux-nightly-x86_64.tar.xz - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - - android: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Install Dependencies - run: | - sudo apt-get update -y - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse xmlstarlet - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - curl -sS https://webi.sh/yq | sh - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generate Secrets + - name: Install ${{matrix.platform}} dependencies run: | flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + dart cli/cli.dart install-dependencies --platform=${{matrix.platform}} - name: Sign Apk + if: ${{matrix.platform == 'android'}} run: | echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties - - - name: Build Apk - run: | - flutter build apk --flavor ${{ inputs.channel }} - mv build/app/outputs/flutter-apk/app-${{ inputs.channel }}-release.apk build/Spotube-android-all-arch.apk - - - name: Build Playstore AppBundle - run: | - echo 'ENABLE_UPDATE_CHECK=0' >> .env - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - export MANIFEST=android/app/src/main/AndroidManifest.xml - xmlstarlet ed -d '//meta-data[@android:name="com.google.android.gms.car.application"]' $MANIFEST > $MANIFEST.tmp - mv $MANIFEST.tmp $MANIFEST - flutter build appbundle --flavor ${{ inputs.channel }} - mv build/app/outputs/bundle/${{ inputs.channel }}Release/app-${{ inputs.channel }}-release.aab build/Spotube-playstore-all-arch.aab - - + + - name: Build ${{matrix.platform}} binaries + run: dart cli/cli.dart build ${{matrix.platform}} + env: + CHANNEL: ${{inputs.channel}} + DOTENV: ${{secrets.DOTENV_RELEASE}} + - uses: actions/upload-artifact@v3 with: if-no-files-found: error name: Spotube-Release-Binaries - path: | - build/Spotube-android-all-arch.apk - build/Spotube-playstore-all-arch.aab + path: ${{matrix.files}} - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} @@ -282,135 +112,10 @@ jobs: with: limit-access-to-actor: true - macos: - - runs-on: macos-14 - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.12.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - brew install yq - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generate Secrets - run: | - dart pub global activate flutter_distributor - flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - - name: Build Macos App - run: | - flutter config --enable-macos-desktop - flutter build macos - du -sh build/macos/Build/Products/Release/spotube.app - - - name: Package Macos App - run: | - brew install python-setuptools - npm install -g appdmg - mkdir -p build/${{ env.BUILD_VERSION }} - appdmg appdmg.json build/Spotube-macos-universal.dmg - flutter_distributor package --platform=macos --targets pkg --skip-clean - mv dist/**/spotube-*-macos.pkg build/Spotube-macos-universal.pkg - - - uses: actions/upload-artifact@v3 - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - build/Spotube-macos-universal.dmg - build/Spotube-macos-universal.pkg - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - - iOS: - runs-on: macos-14 - steps: - - uses: actions/checkout@v4 - - uses: subosito/flutter-action@v2.10.0 - with: - cache: true - flutter-version: ${{ env.FLUTTER_VERSION }} - - - name: Replace pubspec version and BUILD_VERSION Env (nightly) - if: ${{ inputs.channel == 'nightly' }} - run: | - brew install yq - yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV - - - name: BUILD_VERSION Env (stable) - if: ${{ inputs.channel == 'stable' }} - run: | - echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV - - - name: Create Stable .env - if: ${{ inputs.channel == 'stable' }} - run: echo '${{ secrets.DOTENV_RELEASE }}' > .env - - - name: Create Nightly .env - if: ${{ inputs.channel == 'nightly' }} - run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env - - - name: Generate Secrets - run: | - flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - - name: Build iOS iPA - run: | - flutter build ios --release --no-codesign --flavor ${{ inputs.channel }} - ln -sf ./build/ios/iphoneos Payload - zip -r9 Spotube-iOS.ipa Payload/${{ inputs.channel }}.app - - - uses: actions/upload-artifact@v3 - with: - if-no-files-found: error - name: Spotube-Release-Binaries - path: | - Spotube-iOS.ipa - - - name: Debug With SSH When fails - if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} - uses: mxschmitt/action-tmate@v3 - with: - limit-access-to-actor: true - upload: runs-on: ubuntu-latest - needs: - - windows - - linux - - android - - macos - - iOS + - build_platform steps: - uses: actions/download-artifact@v3 with: @@ -426,6 +131,10 @@ jobs: md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum + + - name: Extract pubspec version + run: | + echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV - uses: actions/upload-artifact@v3 with: @@ -440,7 +149,7 @@ jobs: uses: ncipollo/release-action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} - tag: v${{ inputs.version }} # mind the "v" prefix + tag: v${{ env.PUBSPEC_VERSION }} # mind the "v" prefix omitBodyDuringUpdate: true omitNameDuringUpdate: true omitPrereleaseDuringUpdate: true @@ -458,3 +167,8 @@ jobs: omitPrereleaseDuringUpdate: true allowUpdates: true artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum + body: | + Build Number: ${{github.run_number}} + + Nightly release includes newest features but may contain bugs + It is preferred to use the stable version unless you know what you're doing diff --git a/.metadata b/.metadata index 082985ad..828f2c0a 100644 --- a/.metadata +++ b/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled. +# This file should be version controlled and should not be manually edited. version: - revision: eb6d86ee27deecba4a83536aa20f366a6044895c - channel: stable + revision: "300451adae589accbece3490f4396f10bdf15e6e" + channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c - base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c - - platform: macos - create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c - base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + create_revision: 300451adae589accbece3490f4396f10bdf15e6e + base_revision: 300451adae589accbece3490f4396f10bdf15e6e + - platform: windows + create_revision: 300451adae589accbece3490f4396f10bdf15e6e + base_revision: 300451adae589accbece3490f4396f10bdf15e6e # User provided section diff --git a/.vscode/settings.json b/.vscode/settings.json index 29c5ba4e..de5fbd69 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,5 +24,6 @@ "explorer.fileNesting.patterns": { "pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,flutter_launcher_icons*.yaml,flutter_native_splash*.yaml", "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", + "*.dart": "${capture}.g.dart,${capture}.freezed.dart", } } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ca4b69..21fb79d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,39 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. -## [3.6.0-0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0-0) (2024-04-15) +## [3.7.0](https://github.com/krtirtho/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) + + +### Features + +* local library folder cards ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) +* Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) +* personalized stats based on local music history ([#1522](https://github.com/krtirtho/spotube/issues/1522)) ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) +* play initially available tracks of playlist/album immediately and fetch rest in background [#670](https://github.com/krtirtho/spotube/issues/670) ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) +* **player:** add volume slider floating label showing percentage ([#1445](https://github.com/krtirtho/spotube/issues/1445)) ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +* **translations:** add Basque translation ([#1493](https://github.com/krtirtho/spotube/issues/1493)) ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) +* **translations:** add Finnish translations ([#1449](https://github.com/krtirtho/spotube/issues/1449)) ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +* **translations:** add georgian language ([#1450](https://github.com/krtirtho/spotube/issues/1450)) ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) +* **translations:** add Indonesian translation ([#1426](https://github.com/krtirtho/spotube/issues/1426)) ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +* **translations:** Improve tr locales ([#1419](https://github.com/krtirtho/spotube/issues/1419)) ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +* upgrade to Flutter 3.22.0 ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) + + +### Bug Fixes + +* fallback to LRCLIB when lyrics line less than 6 lines [#1461](https://github.com/krtirtho/spotube/issues/1461) ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) +* **linux:** tray icon not showing [#541](https://github.com/krtirtho/spotube/issues/541) ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) +* local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) +* **macos:** Logs directory not created by default [#1353](https://github.com/krtirtho/spotube/issues/1353) ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) +* **playback:** skipping tracks with unplayable sources instead of falling back [#1492](https://github.com/krtirtho/spotube/issues/1492) ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) +* **search:** load more button not working [#1417](https://github.com/krtirtho/spotube/issues/1417) ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) +* some text are garbled in different parts of the app [#1463](https://github.com/krtirtho/spotube/issues/1463) [#1505](https://github.com/krtirtho/spotube/issues/1505) ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) +* spotify friends and user profile icon (mobile) showing when not authenticated [#1410](https://github.com/krtirtho/spotube/issues/1410) ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) +* **updater:** dead link ([#1408](https://github.com/krtirtho/spotube/issues/1408)) ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) +* windows SSL Certificate error breaking login [#905](https://github.com/krtirtho/spotube/issues/905) ([#1474](https://github.com/krtirtho/spotube/issues/1474)) ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), closes [#1468](https://github.com/krtirtho/spotube/issues/1468) +* **windows:** installer tries to install in current directory ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) + +## [3.6.0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) ### Features diff --git a/android/app/build.gradle b/android/app/build.gradle index 2f85cdeb..7bcd9b6a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,9 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,11 +12,6 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -21,10 +22,6 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { @@ -71,6 +68,9 @@ android { release { signingConfig signingConfigs.release } + debug { + signingConfig signingConfigs.release + } } flavorDimensions "default" @@ -81,16 +81,19 @@ android { resValue "string", "app_name_en", "Spotube Nightly" applicationIdSuffix ".nightly" versionNameSuffix "-nightly" + signingConfig signingConfigs.release } dev { dimension "default" resValue "string", "app_name_en", "Spotube Dev" applicationIdSuffix ".dev" versionNameSuffix "-dev" + signingConfig signingConfigs.release } stable { dimension "default" resValue "string", "app_name_en", "Spotube" + signingConfig signingConfigs.release } } @@ -101,15 +104,6 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - constraints { - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version") { - because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib") - } - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version") { - because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib") - } - } implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' // other deps so just ignore diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5ab7a0b5..589e22ff 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,6 +24,11 @@ android:usesCleartextTraffic="true" android:requestLegacyExternalStorage="true" > + + + properties.load(reader) } + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.2.1" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" \ No newline at end of file diff --git a/bin/gen-credits.dart b/bin/gen-credits.dart deleted file mode 100644 index f8975335..00000000 --- a/bin/gen-credits.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'dart:developer'; -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:http/http.dart'; -import 'package:html/parser.dart'; -import 'package:pub_api_client/pub_api_client.dart'; -import 'package:pubspec_parse/pubspec_parse.dart'; - -void main() async { - final client = PubClient(); - - final pubspec = Pubspec.parse(File('pubspec.yaml').readAsStringSync()); - - final allDeps = [ - ...pubspec.dependencies.entries, - ...pubspec.devDependencies.entries, - ]; - - final dependencies = allDeps - .where((d) => d.value is HostedDependency) - .map((d) => d.key) - .toSet(); - final packageInfo = await Future.wait(dependencies.map(client.packageInfo)); - - final gitDepsList = List.castFrom, - MapEntry>( - allDeps - .where((d) => d.value is GitDependency) - .map((d) => MapEntry(d.key, d.value as GitDependency)) - .toList(), - ); - - final gitDeps = gitDepsList.map( - (d) { - final uri = Uri.parse( - d.value.url.toString().replaceAll('.git', ''), - ); - return MapEntry( - d.key, - uri.replace( - pathSegments: [ - ...uri.pathSegments, - 'raw', - d.value.ref ?? 'main', - d.value.path ?? '', - 'pubspec.yaml', - ], - ).toString(), - ); - }, - ).toList(); - - final gitPubspecs = await Future.wait( - gitDeps.map( - (d) { - Pubspec parser(res) { - try { - return Pubspec.parse(res.body); - } catch (e) { - final document = parse(res.body); - final pre = document.querySelector('pre'); - if (pre == null) { - log(d.toString()); - rethrow; - } - return Pubspec.parse(pre.text); - } - } - - return get(Uri.parse(d.value)).then(parser).catchError( - (_) => get(Uri.parse(d.value.replaceFirst('/main', '/master'))) - .then(parser), - ); - }, - ), - ); - - // ignore: avoid_print - print( - packageInfo - .map( - (package) => - '1. [${package.name}](${package.latestPubspec.homepage ?? package.url}) - ${package.description.replaceAll('\n', '')}', - ) - .join('\n'), - ); - // ignore: avoid_print - print( - gitPubspecs.map( - (package) { - final packageUrl = package.homepage ?? - gitDepsList - .firstWhereOrNull((dep) => dep.key == package.name) - ?.value - .url - .toString(); - return '1. [${package.name}]($packageUrl) - ${package.description?.replaceAll('\n', '')}'; - }, - ).join('\n'), - ); - exit(0); -} diff --git a/bin/translated_messages.dart b/bin/translated_messages.dart deleted file mode 100644 index 1ac8f148..00000000 --- a/bin/translated_messages.dart +++ /dev/null @@ -1,28 +0,0 @@ -// ignore_for_file: avoid_print - -import 'dart:convert'; -import 'dart:io'; - -void main(List args) async { - final translatedFile = - jsonDecode(await File('tm.json').readAsString()) as Map; - - for (final MapEntry(:key, :value) in translatedFile.entries) { - print('Updating locale: $key'); - final file = File('lib/l10n/app_$key.arb'); - - final fileContent = - jsonDecode(await file.readAsString()) as Map; - - final newContent = { - ...fileContent, - ...value, - }; - - await file.writeAsString( - const JsonEncoder.withIndent(' ').convert(newContent), - ); - - print('✅ Updated locale: $key'); - } -} diff --git a/bin/untranslated_messages.dart b/bin/untranslated_messages.dart deleted file mode 100644 index 0b3485a7..00000000 --- a/bin/untranslated_messages.dart +++ /dev/null @@ -1,50 +0,0 @@ -// ignore_for_file: avoid_print - -import 'dart:convert'; -import 'dart:io'; - -/// Generate JSON output for untranslated messages with English values -/// for quick translation in ChatGPT -/// -/// Usage: dart bin/untranslated_messages.dart [locale?] -/// -/// Example: dart bin/untranslated_messages.dart -/// -/// or with specific locale (e.g. bn (Bengali)) -/// -/// Example: dart bin/untranslated_messages.dart bn - -void main(List args) { - final file = jsonDecode( - File('untranslated_messages.json').readAsStringSync(), - ) as Map; - - final englishMessages = - jsonDecode(File('lib/l10n/app_en.arb').readAsStringSync()) - as Map; - - final messagesWithValues = {}; - - for (final MapEntry(key: locale, value: messages) in file.entries) { - messagesWithValues[locale] = Map.fromEntries( - messages - .map( - (message) => - MapEntry(message, englishMessages[message]), - ) - .toList() - .cast>(), - ); - } - - 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.", - ); - print( - const JsonEncoder.withIndent(' ').convert( - args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues, - ), - ); -} diff --git a/bin/verify-pkgbuild.dart b/bin/verify-pkgbuild.dart deleted file mode 100644 index 587e63d0..00000000 --- a/bin/verify-pkgbuild.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -void main() { - Process.run("sh", ["-c", '"./scripts/pkgbuild2json.sh aur-struct/PKGBUILD"']) - .then((result) { - try { - final pkgbuild = jsonDecode(result.stdout); - if (pkgbuild["version"] != - Platform.environment["RELEASE_VERSION"]?.substring(1)) { - throw Exception( - "PKGBUILD version doesn't match current RELEASE_VERSION"); - } - if (pkgbuild["release"] != "1") { - throw Exception("In new releases pkgrel should be 1"); - } - } catch (e) { - // ignore: avoid_print - print("[Failed to parse PKGBUILD] $e"); - } - }); -} diff --git a/build.yaml b/build.yaml index f074d6e1..d83d6a20 100644 --- a/build.yaml +++ b/build.yaml @@ -2,4 +2,9 @@ targets: $default: sources: exclude: - - bin/*.dart \ No newline at end of file + - bin/*.dart + builders: + json_serializable: + options: + any_map: true + explicit_to_json: true diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 00000000..b2ba8ebd --- /dev/null +++ b/cli/README.md @@ -0,0 +1,4 @@ +## Spotube Configuration CLI + +This is used for building the project for multiple platforms and having utilities specific for the project. +Written in Dart diff --git a/cli/cli.dart b/cli/cli.dart new file mode 100644 index 00000000..26190d4c --- /dev/null +++ b/cli/cli.dart @@ -0,0 +1,22 @@ +import 'package:args/command_runner.dart'; + +import 'commands/build.dart'; +import 'commands/credits.dart'; +import 'commands/install-dependencies.dart'; +import 'commands/translated.dart'; +import 'commands/untranslated.dart'; + +void main(List args) { + final commandRunner = CommandRunner( + "cli", + "Configuration CLI for Spotube", + ); + + commandRunner.addCommand(InstallDependenciesCommand()); + commandRunner.addCommand(BuildCommand()); + commandRunner.addCommand(CreditsCommand()); + commandRunner.addCommand(TranslatedCommand()); + commandRunner.addCommand(UntranslatedCommand()); + + commandRunner.run(args); +} diff --git a/cli/commands/build.dart b/cli/commands/build.dart new file mode 100644 index 00000000..fdf35a95 --- /dev/null +++ b/cli/commands/build.dart @@ -0,0 +1,25 @@ +import 'package:args/command_runner.dart'; + +import 'build/android.dart'; +import 'build/ios.dart'; +import 'build/linux.dart'; +import 'build/linux_arm.dart'; +import 'build/macos.dart'; +import 'build/windows.dart'; + +class BuildCommand extends Command { + @override + String get description => "Build for different platforms"; + + @override + String get name => "build"; + + BuildCommand() { + addSubcommand(AndroidBuildCommand()); + addSubcommand(IosBuildCommand()); + addSubcommand(LinuxBuildCommand()); + addSubcommand(LinuxArmBuildCommand()); + addSubcommand(MacosBuildCommand()); + addSubcommand(WindowsBuildCommand()); + } +} diff --git a/cli/commands/build/android.dart b/cli/commands/build/android.dart new file mode 100644 index 00000000..800522b8 --- /dev/null +++ b/cli/commands/build/android.dart @@ -0,0 +1,90 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:collection/collection.dart'; +import 'package:path/path.dart'; +import 'package:xml/xml.dart'; + +import '../../core/env.dart'; +import 'common.dart'; + +class AndroidBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Build for android"; + + @override + String get name => "android"; + + @override + FutureOr? run() async { + await bootstrap(); + + await shell.run( + "flutter build apk --flavor ${CliEnv.channel.name}", + ); + + await dotEnvFile.writeAsString( + "\nENABLE_UPDATE_CHECK=0", + mode: FileMode.append, + ); + + final androidManifestFile = File( + join(cwd.path, "android", "app", "src", "main", "AndroidManifest.xml")); + + final androidManifestXml = + XmlDocument.parse(await androidManifestFile.readAsString()); + + final deletingElement = + androidManifestXml.findAllElements("meta-data").firstWhereOrNull( + (el) => + el.getAttribute("android:name") == + "com.google.android.gms.car.application", + ); + + deletingElement?.parent?.children.remove(deletingElement); + + await androidManifestFile.writeAsString( + androidManifestXml.toXmlString(pretty: true), + ); + + await shell.run( + """ + dart run build_runner build --delete-conflicting-outputs + flutter build appbundle --flavor ${CliEnv.channel.name} + """, + ); + + final ogApkFile = File( + join( + "build", + "app", + "outputs", + "flutter-apk", + "app-${CliEnv.channel.name}-release.apk", + ), + ); + + await ogApkFile.copy( + join(cwd.path, "build", "Spotube-android-all-arch.apk"), + ); + + final ogAppbundleFile = File( + join( + cwd.path, + "build", + "app", + "outputs", + "bundle", + "${CliEnv.channel.name}Release", + "app-${CliEnv.channel.name}-release.aab", + ), + ); + + await ogAppbundleFile.copy( + join(cwd.path, "build", "Spotube-playstore-all-arch.aab"), + ); + + stdout.writeln("✅ Built Android Apk and Appbundle"); + } +} diff --git a/cli/commands/build/common.dart b/cli/commands/build/common.dart new file mode 100644 index 00000000..4c7e3e51 --- /dev/null +++ b/cli/commands/build/common.dart @@ -0,0 +1,66 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; +import 'package:process_run/shell_run.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; + +import '../../core/env.dart'; + +mixin BuildCommandCommonSteps on Command { + final shell = Shell(); + Directory get cwd => Directory.current; + + Pubspec? _pubspec; + + Pubspec get pubspec { + if (_pubspec != null) { + return _pubspec!; + } + + final pubspecFile = File(join(cwd.path, "pubspec.yaml")); + _pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); + + return _pubspec!; + } + + String get versionWithoutBuildNumber { + return "${pubspec.version!.major}.${pubspec.version!.minor}.${pubspec.version!.patch}"; + } + + RegExp get versionVarRegExp => + RegExp(r"\%\{\{SPOTUBE_VERSION\}\}\%", multiLine: true); + + File get dotEnvFile => File(join(cwd.path, ".env")); + + Future bootstrap() async { + await dotEnvFile.create(recursive: true); + + await dotEnvFile.writeAsString( + "${CliEnv.dotenv}\n" + "RELEASE_CHANNEL=${CliEnv.channel.name}\n", + ); + + if (CliEnv.channel == BuildChannel.nightly) { + final pubspecFile = File(join(cwd.path, "pubspec.yaml")); + + pubspecFile.writeAsStringSync( + pubspecFile.readAsStringSync().replaceAll( + "version: ${pubspec.version!.canonicalizedVersion}", + "version: $versionWithoutBuildNumber+${CliEnv.ghRunNumber}", + ), + ); + + _pubspec = null; + pubspec; + } + + await shell.run( + """ + flutter pub get + dart run build_runner build --delete-conflicting-outputs + dart pub global activate flutter_distributor + """, + ); + } +} diff --git a/cli/commands/build/ios.dart b/cli/commands/build/ios.dart new file mode 100644 index 00000000..6460f9ed --- /dev/null +++ b/cli/commands/build/ios.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; + +import '../../core/env.dart'; +import 'common.dart'; + +class IosBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "iOS build command"; + + @override + String get name => "ios"; + + @override + FutureOr? run() async { + await bootstrap(); + + final buildDirPath = join(cwd.path, "build", "ios", "iphoneos"); + await shell.run( + """ + flutter build ios --release --no-codesign --flavor ${CliEnv.channel.name} + ln -sf $buildDirPath Payload + zip -r9 Spotube-iOS.ipa ${join("Payload", "${CliEnv.channel.name}.app")} + """, + ); + } +} diff --git a/cli/commands/build/linux.dart b/cli/commands/build/linux.dart new file mode 100644 index 00000000..a218720c --- /dev/null +++ b/cli/commands/build/linux.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:io/io.dart'; +import 'package:args/command_runner.dart'; +import 'package:intl/intl.dart'; +import 'package:path/path.dart'; + +import '../../core/env.dart'; +import 'common.dart'; + +class LinuxBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Linux build command"; + + @override + String get name => "linux"; + + @override + FutureOr? run() async { + stdout.writeln("Replacing versions"); + + final appDataFile = File( + join(cwd.path, "linux", "com.github.KRTirtho.Spotube.appdata.xml"), + ); + + appDataFile.writeAsStringSync( + appDataFile.readAsStringSync().replaceAll( + versionVarRegExp, + '', + ), + ); + + await bootstrap(); + + await shell.run( + """ + flutter_distributor package --platform=linux --targets=deb + flutter_distributor package --platform=linux --targets=rpm + """, + ); + + final tempDir = join(Directory.systemTemp.path, "spotube-tar"); + + final bundleDirPath = + join(cwd.path, "build", "linux", "x64", "release", "bundle"); + + final tarFile = File(join( + cwd.path, + "dist", + "spotube-linux-" + "${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}" + "-x86_64.tar.xz", + )); + + await copyPath(bundleDirPath, tempDir); + await File(join(cwd.path, "linux", "spotube.desktop")).copy( + join(tempDir, "spotube.desktop"), + ); + await File( + join(cwd.path, "linux", "com.github.KRTirtho.Spotube.appdata.xml"), + ).copy( + join(tempDir, "com.github.KRTirtho.Spotube.appdata.xml"), + ); + await File(join(cwd.path, "assets", "spotube-logo.png")).copy( + join(tempDir, "spotube-logo.png"), + ); + + await shell.run( + "tar -cJf ${tarFile.path} -C $tempDir .", + ); + + final ogDeb = File( + join( + cwd.path, + "dist", + pubspec.version.toString(), + "spotube-${pubspec.version}-linux.deb", + ), + ); + + final ogRpm = File( + join( + cwd.path, + "dist", + pubspec.version.toString(), + "spotube-${pubspec.version}-linux.rpm", + ), + ); + + await ogDeb.copy( + join(cwd.path, "dist", "Spotube-linux-x86_64.deb"), + ); + await ogRpm.copy( + join(cwd.path, "dist", "Spotube-linux-x86_64.rpm"), + ); + + await ogDeb.delete(); + await ogRpm.delete(); + + stdout.writeln("✅ Linux building done"); + } +} diff --git a/cli/commands/build/linux_arm.dart b/cli/commands/build/linux_arm.dart new file mode 100644 index 00000000..a09f0980 --- /dev/null +++ b/cli/commands/build/linux_arm.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; + +import '../../core/env.dart'; +import 'common.dart'; + +class LinuxArmBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Build Linux Arm"; + + @override + String get name => "linux_arm"; + + @override + FutureOr? run() async { + await bootstrap(); + + await shell.run( + "docker buildx build --platform=linux/arm64 " + "-f ${join(cwd.path, ".github", "Dockerfile")} ${cwd.path} " + "--build-arg FLUTTER_VERSION=${CliEnv.flutterVersion} " + "--build-arg BUILD_VERSION=${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber} " + "-t krtirtho/spotube_linux_arm:latest " + "--load", + ); + + await shell.run( + """ + docker images ls + docker create --name spotube_linux_arm krtirtho/spotube_linux_arm:latest + docker cp spotube_linux_arm:/app/dist/ dist/ + """, + ); + } +} diff --git a/cli/commands/build/macos.dart b/cli/commands/build/macos.dart new file mode 100644 index 00000000..e8f34b77 --- /dev/null +++ b/cli/commands/build/macos.dart @@ -0,0 +1,42 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; + +import 'common.dart'; + +class MacosBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Macos Build command"; + + @override + String get name => "macos"; + + @override + FutureOr? run() async { + await bootstrap(); + + await shell.run( + """ + flutter build macos + appdmg appdmg.json ${join(cwd.path, "build", "Spotube-macos-universal.dmg")} + flutter_distributor package --platform=macos --targets pkg --skip-clean + """, + ); + + final ogPkg = File( + join( + cwd.path, + "dist", + pubspec.version.toString(), + "spotube-${pubspec.version}-macos.pkg", + ), + ); + + await ogPkg.copy( + join(cwd.path, "build", "Spotube-macos-universal.pkg"), + ); + await ogPkg.delete(); + } +} diff --git a/cli/commands/build/windows.dart b/cli/commands/build/windows.dart new file mode 100644 index 00000000..15e0bf17 --- /dev/null +++ b/cli/commands/build/windows.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; +import 'package:crypto/crypto.dart'; +import 'common.dart'; + +class WindowsBuildCommand extends Command with BuildCommandCommonSteps { + @override + String get description => "Build Windows exe"; + + @override + String get name => "windows"; + + Future innoDependInstall() async { + final innoDependencyPath = join(cwd.path, "build", "inno-depend"); + + await shell.run( + "git clone https://github.com/DomGries/InnoDependencyInstaller.git $innoDependencyPath", + ); + } + + @override + void run() async { + stdout.writeln("Replace versions"); + + final chocoFiles = [ + join(cwd.path, "choco-struct", "tools", "VERIFICATION.txt"), + join(cwd.path, "choco-struct", "spotube.nuspec"), + ]; + + for (final filePath in chocoFiles) { + final file = File(filePath); + final content = file.readAsStringSync(); + final newContent = + content.replaceAll(versionVarRegExp, versionWithoutBuildNumber); + + file.writeAsStringSync(newContent); + } + + await bootstrap(); + await innoDependInstall(); + + await shell.run( + "flutter_distributor package --platform=windows --targets=exe --skip-clean", + ); + + final ogExe = File( + join( + cwd.path, + "dist", + pubspec.version.toString(), + "spotube-${pubspec.version}-windows-setup.exe", + ), + ); + + final exePath = join(cwd.path, "dist", "Spotube-windows-x86_64-setup.exe"); + + await ogExe.copy(exePath); + await ogExe.delete(); + + stdout.writeln("✅ Windows exe built at $exePath"); + + final exeFile = File(exePath); + + final hash = sha256.convert(await exeFile.readAsBytes()).toString(); + + final chocoVerificationFile = File(chocoFiles.first); + + chocoVerificationFile.writeAsStringSync( + chocoVerificationFile.readAsStringSync().replaceAll( + RegExp(r"\%\{\{WIN_SHA256\}\}\%"), + hash, + ), + ); + + await exeFile.copy( + join(cwd.path, "choco-struct", "tools", basename(exeFile.path)), + ); + + await shell.run( + "choco pack ${chocoFiles[1]} --outputdirectory ${join(cwd.path, "dist")}", + ); + + final chocoNupkg = File( + join(cwd.path, "dist", "spotube.$versionWithoutBuildNumber.nupkg"), + ); + + final distNupkgPath = join( + cwd.path, + "dist", + "Spotube-windows-x86_64.nupkg", + ); + + await chocoNupkg.copy(distNupkgPath); + await chocoNupkg.delete(); + + stdout.writeln("✅ Windows nupkg built at $distNupkgPath"); + } +} diff --git a/cli/commands/credits.dart b/cli/commands/credits.dart new file mode 100644 index 00000000..6bad7a44 --- /dev/null +++ b/cli/commands/credits.dart @@ -0,0 +1,121 @@ +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; +import 'package:html/parser.dart'; +import 'package:path/path.dart'; +import 'package:pub_api_client/pub_api_client.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; + +class CreditsCommand extends Command { + final dio = Dio( + BaseOptions( + responseType: ResponseType.plain, + ), + ); + + @override + String get description => "Generate credits for used Library's authors"; + + @override + String get name => "credits"; + + @override + run() async { + final client = PubClient(); + final cwd = Directory.current; + + final pubspec = Pubspec.parse( + File(join(cwd.path, 'pubspec.yaml')).readAsStringSync(), + ); + + final allDeps = [ + ...pubspec.dependencies.entries, + ...pubspec.devDependencies.entries, + ]; + + final dependencies = allDeps + .where((d) => d.value is HostedDependency) + .map((d) => d.key) + .toSet(); + final packageInfo = await Future.wait(dependencies.map(client.packageInfo)); + + final gitDepsList = List.castFrom, + MapEntry>( + allDeps + .where((d) => d.value is GitDependency) + .map((d) => MapEntry(d.key, d.value as GitDependency)) + .toList(), + ); + + final gitDeps = gitDepsList.map( + (d) { + final uri = Uri.parse( + d.value.url.toString().replaceAll('.git', ''), + ); + return MapEntry( + d.key, + uri.replace( + pathSegments: [ + ...uri.pathSegments, + 'raw', + d.value.ref ?? 'main', + d.value.path ?? '', + 'pubspec.yaml', + ], + ).toString(), + ); + }, + ).toList(); + + final gitPubspecs = await Future.wait( + gitDeps.map( + (d) { + Pubspec parser(Response res) { + try { + return Pubspec.parse(res.data); + } catch (e) { + final document = parse(res.data); + final pre = document.querySelector('pre'); + if (pre == null) { + stdout.writeln(d.toString()); + rethrow; + } + return Pubspec.parse(pre.text); + } + } + + return dio.get(d.value).then(parser).catchError( + (_) => dio + .get(d.value.replaceFirst('/main', '/master')) + .then(parser), + ); + }, + ), + ); + + stdout.writeln( + packageInfo + .map( + (package) => + '1. [${package.name}](${package.latestPubspec.homepage ?? package.url}) - ${package.description.replaceAll('\n', '')}', + ) + .join('\n'), + ); + + stdout.writeln( + gitPubspecs.map( + (package) { + final packageUrl = package.homepage ?? + gitDepsList + .firstWhereOrNull((dep) => dep.key == package.name) + ?.value + .url + .toString(); + return '1. [${package.name}]($packageUrl) - ${package.description?.replaceAll('\n', '')}'; + }, + ).join('\n'), + ); + } +} diff --git a/cli/commands/install-dependencies.dart b/cli/commands/install-dependencies.dart new file mode 100644 index 00000000..75df28df --- /dev/null +++ b/cli/commands/install-dependencies.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:args/command_runner.dart'; +import 'package:process_run/shell_run.dart'; + +class InstallDependenciesCommand extends Command { + @override + String get description => "Install platform dependencies"; + + @override + String get name => "install-dependencies"; + + InstallDependenciesCommand() { + argParser.addOption( + "platform", + abbr: "p", + allowed: [ + "windows", + "linux", + "linux_arm", + "macos", + "ios", + "android", + ], + mandatory: true, + ); + } + + @override + FutureOr? run() async { + final shell = Shell(); + + switch (argResults!.option("platform")) { + case "windows": + break; + case "linux": + await shell.run( + """ + sudo apt-get update -y + sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev + """, + ); + break; + case "linux_arm": + await shell.run( + """ + sudo apt-get update -y + sudo apt-get install -y pkg-config make python3-pip python3-setuptools + """, + ); + break; + case "macos": + await shell.run( + """ + brew install python-setuptools + npm install -g appdmg + """, + ); + break; + case "ios": + break; + case "android": + await shell.run( + """ + sudo apt-get update -y + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse + """, + ); + break; + default: + break; + } + } +} diff --git a/cli/commands/translated.dart b/cli/commands/translated.dart new file mode 100644 index 00000000..43c4ea49 --- /dev/null +++ b/cli/commands/translated.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'dart:convert'; +import 'dart:io'; +import 'package:args/command_runner.dart'; +import 'package:path/path.dart'; + +class TranslatedCommand extends Command { + @override + String get description => + "Update translation based on generated translated messages"; + + @override + String get name => "translated"; + + @override + FutureOr? run() async { + final cwd = Directory.current; + final translatedFile = jsonDecode( + await File(join(cwd.path, 'tm.json')).readAsString(), + ) as Map; + + for (final MapEntry(:key, :value) in translatedFile.entries) { + stdout.writeln('Updating locale: $key'); + final file = File(join(cwd.path, 'lib', 'l10n', 'app_$key.arb')); + + final fileContent = + jsonDecode(await file.readAsString()) as Map; + + final newContent = {...fileContent, ...value}; + + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(newContent), + ); + + stdout.writeln('✅ Updated locale: $key'); + } + } +} diff --git a/cli/commands/untranslated.dart b/cli/commands/untranslated.dart new file mode 100644 index 00000000..dadcd8b5 --- /dev/null +++ b/cli/commands/untranslated.dart @@ -0,0 +1,48 @@ +import 'package:args/command_runner.dart'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart'; + +class UntranslatedCommand extends Command { + @override + get name => "untranslated"; + @override + get description => + "Generate Untranslated Messages for ChatGPT based Translation"; + + @override + run() async { + final cwd = Directory.current; + final file = jsonDecode( + File(join(cwd.path, 'untranslated_messages.json')).readAsStringSync(), + ) as Map; + + final englishMessages = jsonDecode( + File(join(cwd.path, 'lib', 'l10n', 'app_en.arb')).readAsStringSync(), + ) as Map; + + final messagesWithValues = {}; + + for (final MapEntry(key: locale, value: messages) in file.entries) { + messagesWithValues[locale] = Map.fromEntries( + messages + .map( + (message) => + MapEntry(message, englishMessages[message]), + ) + .toList() + .cast>(), + ); + } + + stdout.writeln( + "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.", + ); + stdout.writeln( + const JsonEncoder.withIndent(' ').convert(messagesWithValues), + ); + } +} diff --git a/cli/core/env.dart b/cli/core/env.dart new file mode 100644 index 00000000..33cc5df1 --- /dev/null +++ b/cli/core/env.dart @@ -0,0 +1,24 @@ +import 'dart:io'; + +enum BuildChannel { + stable, + nightly; + + factory BuildChannel.fromEnvironment(String name) { + final channel = Platform.environment[name]!; + if (channel == "stable") { + return BuildChannel.stable; + } else if (channel == "nightly") { + return BuildChannel.nightly; + } else { + throw Exception("Invalid channel: $channel"); + } + } +} + +class CliEnv { + static final channel = BuildChannel.fromEnvironment("CHANNEL"); + static final dotenv = Platform.environment["DOTENV"]!; + static final ghRunNumber = Platform.environment["GITHUB_RUN_NUMBER"]; + static final flutterVersion = Platform.environment["FLUTTER_VERSION"]!; +} diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..7e7e7f67 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1 @@ +extensions: diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1d048cc9..f8533902 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -69,9 +69,6 @@ PODS: - fluttertoast (0.0.2): - Flutter - Toast - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - image_picker_ios (0.0.1): - Flutter - integration_test (0.0.1): @@ -87,7 +84,7 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - permission_handler_apple (9.1.1): + - permission_handler_apple (9.3.0): - Flutter - SDWebImage (5.18.8): - SDWebImage/Core (= 5.18.8) @@ -97,7 +94,7 @@ PODS: - FlutterMacOS - sqflite (0.0.3): - Flutter - - FMDB (>= 2.7.5) + - FlutterMacOS - SwiftyGif (5.4.4) - Toast (4.0.0) - url_launcher_ios (0.0.1): @@ -129,14 +126,13 @@ DEPENDENCIES: - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `.symlinks/plugins/sqflite/ios`) + - sqflite (from `.symlinks/plugins/sqflite/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery - - FMDB - OrderedSet - SDWebImage - SwiftyGif @@ -194,45 +190,44 @@ EXTERNAL SOURCES: shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite: - :path: ".symlinks/plugins/sqflite/ios" + :path: ".symlinks/plugins/sqflite/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 + app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795 audio_service: f509d65da41b9521a61f1c404dd58651f265a567 - audio_session: 4f3e461722055d21515cf3261b64c973c062f345 + audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 - device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 + device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de - file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b + file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 + file_selector_ios: 78baf21d03f1e37a7df97bb2494f9cd86de8fa5d Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 - flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef + flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 - fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 - integration_test: 13825b8a9334a850581300559b8839134b124670 + fluttertoast: 9f2f8e81bb5ce18facb9748d7855bf5a756fe3db + image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18 + integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3 media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c - package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 - permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 + package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 + url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 13f624a4..34793f68 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -324,6 +324,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */, + 46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -346,6 +347,7 @@ B536BD992B405DB1009B3CE4 /* Embed Frameworks */, B536BD9A2B405DB1009B3CE4 /* Thin Binary */, A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */, + 2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -368,6 +370,7 @@ B536BDB62B405FDE009B3CE4 /* Embed Frameworks */, B536BDB72B405FDE009B3CE4 /* Thin Binary */, 244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */, + 4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -390,6 +393,7 @@ B536BDD82B4060B3009B3CE4 /* Embed Frameworks */, B536BDD92B4060B3009B3CE4 /* Thin Binary */, D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */, + 5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -523,6 +527,23 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + 2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -539,6 +560,57 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 5CD4405E93760FBD048E36E2 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/lib/collections/env.dart b/lib/collections/env.dart index 50fe1e6a..df45cee9 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -1,8 +1,13 @@ import 'package:envied/envied.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:spotube/utils/platform.dart'; part 'env.g.dart'; +enum ReleaseChannel { + nightly, + stable, +} + @Envied(obfuscate: true, requireEnvFile: true, path: ".env") abstract class Env { @EnviedField(varName: 'SPOTIFY_SECRETS') @@ -25,8 +30,15 @@ abstract class Env { @EnviedField(varName: 'ENABLE_UPDATE_CHECK', defaultValue: "1") static final String _enableUpdateChecker = _Env._enableUpdateChecker; + @EnviedField(varName: "RELEASE_CHANNEL", defaultValue: "nightly") + static final String _releaseChannel = _Env._releaseChannel; + + static ReleaseChannel get releaseChannel => _releaseChannel == "stable" + ? ReleaseChannel.stable + : ReleaseChannel.nightly; + static bool get enableUpdateChecker => - DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1"; + kIsFlatpak || _enableUpdateChecker == "1"; static String discordAppId = "1176718791388975124"; -} +} \ No newline at end of file diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 4df19dfc..7391d3a0 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -1,5 +1,4 @@ import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; diff --git a/lib/collections/formatters.dart b/lib/collections/formatters.dart new file mode 100644 index 00000000..0aed9e9f --- /dev/null +++ b/lib/collections/formatters.dart @@ -0,0 +1,8 @@ +import 'package:intl/intl.dart'; + +final compactNumberFormatter = NumberFormat.compact(); +final usdFormatter = NumberFormat.compactCurrency( + locale: 'en-US', + symbol: r"$", + decimalDigits: 2, +); diff --git a/lib/collections/initializers.dart b/lib/collections/initializers.dart index 9627de1c..976661fc 100644 --- a/lib/collections/initializers.dart +++ b/lib/collections/initializers.dart @@ -1,9 +1,10 @@ import 'dart:io'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + +import 'package:spotube/utils/platform.dart'; import 'package:win32_registry/win32_registry.dart'; Future registerWindowsScheme(String scheme) async { - if (!DesktopTools.platform.isWindows) return; + if (!kIsWindows) return; String appPath = Platform.resolvedExecutable; String protocolRegKey = 'Software\\Classes\\$scheme'; diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 5f60959e..579aff18 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -7,6 +7,10 @@ import 'package:go_router/go_router.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/models/logger.dart'; +import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/library/library.dart'; +import 'package:spotube/pages/lyrics/lyrics.dart'; +import 'package:spotube/pages/search/search.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; @@ -67,16 +71,16 @@ class HomeTabAction extends Action { final router = intent.ref.read(routerProvider); switch (intent.tab) { case HomeTabs.browse: - router.go("/"); + router.goNamed(HomePage.name); break; case HomeTabs.search: - router.go("/search"); + router.goNamed(SearchPage.name); break; case HomeTabs.library: - router.go("/library"); + router.goNamed(LibraryPage.name); break; case HomeTabs.lyrics: - router.go("/lyrics"); + router.goNamed(LyricsPage.name); break; } return null; diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index 45456d69..f46e0efe 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -81,10 +81,10 @@ abstract class LanguageLocals { // name: "Bashkir", // nativeName: "башҡорт теле", // ), - // "eu": const ISOLanguageName( - // name: "Basque", - // nativeName: "euskara,", - // ), + "eu": const ISOLanguageName( + name: "Basque", + nativeName: "euskara", + ), // "be": const ISOLanguageName( // name: "Belarusian", // nativeName: "Беларуская", @@ -197,10 +197,10 @@ abstract class LanguageLocals { // name: "Fijian", // nativeName: "vosa Vakaviti", // ), - // "fi": const ISOLanguageName( - // name: "Finnish", - // nativeName: "suomi", - // ), + "fi": const ISOLanguageName( + name: "Finnish", + nativeName: "suomi", + ), "fr": const ISOLanguageName( name: "French", nativeName: "français", @@ -213,10 +213,10 @@ abstract class LanguageLocals { // name: "Galician", // nativeName: "Galego", // ), - // "ka": const ISOLanguageName( - // name: "Georgian", - // nativeName: "ქართული", - // ), + "ka": const ISOLanguageName( + name: "Georgian", + nativeName: "ქართული", + ), "de": const ISOLanguageName( name: "German", nativeName: "Deutsch", @@ -265,10 +265,10 @@ abstract class LanguageLocals { // name: "Interlingua", // nativeName: "Interlingua", // ), - // "id": const ISOLanguageName( - // name: "Indonesian", - // nativeName: "Bahasa Indonesia", - // ), + "id": const ISOLanguageName( + name: "Indonesian", + nativeName: "Bahasa Indonesia", + ), // "ie": const ISOLanguageName( // name: "Interlingue", // nativeName: "Occidental", diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 080cbd8a..dc2e4b7c 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -14,6 +14,7 @@ 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/local_folder.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart'; @@ -24,6 +25,13 @@ import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/logs.dart'; +import 'package:spotube/pages/stats/albums/albums.dart'; +import 'package:spotube/pages/stats/artists/artists.dart'; +import 'package:spotube/pages/stats/fees/fees.dart'; +import 'package:spotube/pages/stats/minutes/minutes.dart'; +import 'package:spotube/pages/stats/playlists/playlists.dart'; +import 'package:spotube/pages/stats/stats.dart'; +import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; @@ -50,6 +58,7 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: "/", + name: HomePage.name, redirect: (context, state) async { final authNotifier = ref.read(authenticationProvider.notifier); final json = await authNotifier.box.get(authNotifier.cacheKey); @@ -66,11 +75,13 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: "genres", + name: GenrePage.name, pageBuilder: (context, state) => const SpotubePage(child: GenrePage()), ), GoRoute( path: "genre/:categoryId", + name: GenrePlaylistsPage.name, pageBuilder: (context, state) => SpotubePage( child: GenrePlaylistsPage( category: state.extra as Category, @@ -79,6 +90,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "feeds/:feedId", + name: HomeFeedSectionPage.name, pageBuilder: (context, state) => SpotubePage( child: HomeFeedSectionPage( sectionUri: state.pathParameters["feedId"] as String, @@ -89,45 +101,62 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/search", - name: "Search", + name: SearchPage.name, pageBuilder: (context, state) => const SpotubePage(child: SearchPage()), ), GoRoute( path: "/library", - name: "Library", + name: LibraryPage.name, pageBuilder: (context, state) => const SpotubePage(child: LibraryPage()), routes: [ GoRoute( - path: "generate", - pageBuilder: (context, state) => - const SpotubePage(child: PlaylistGeneratorPage()), - routes: [ - GoRoute( - path: "result", - pageBuilder: (context, state) => SpotubePage( - child: PlaylistGenerateResultPage( - state: state.extra as GeneratePlaylistProviderInput, - ), + path: "generate", + name: PlaylistGeneratorPage.name, + pageBuilder: (context, state) => + const SpotubePage(child: PlaylistGeneratorPage()), + routes: [ + GoRoute( + path: "result", + name: PlaylistGenerateResultPage.name, + pageBuilder: (context, state) => SpotubePage( + child: PlaylistGenerateResultPage( + state: state.extra as GeneratePlaylistProviderInput, ), ), - ]), + ) + ], + ), + GoRoute( + path: "local", + name: LocalLibraryPage.name, + pageBuilder: (context, state) { + assert(state.extra is String); + return SpotubePage( + child: LocalLibraryPage(state.extra as String, + isDownloads: + state.uri.queryParameters["downloads"] != null), + ); + }, + ), ]), GoRoute( path: "/lyrics", - name: "Lyrics", + name: LyricsPage.name, pageBuilder: (context, state) => const SpotubePage(child: LyricsPage()), ), GoRoute( path: "/settings", + name: SettingsPage.name, pageBuilder: (context, state) => const SpotubePage( child: SettingsPage(), ), routes: [ GoRoute( path: "blacklist", + name: BlackListPage.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const BlackListPage(), ), @@ -135,12 +164,14 @@ final routerProvider = Provider((ref) { if (!kIsWeb) GoRoute( path: "logs", + name: LogsPage.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const LogsPage(), ), ), GoRoute( path: "about", + name: AboutSpotube.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const AboutSpotube(), ), @@ -149,6 +180,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/album/:id", + name: AlbumPage.name, pageBuilder: (context, state) { assert(state.extra is AlbumSimple); return SpotubePage( @@ -158,6 +190,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/artist/:id", + name: ArtistPage.name, pageBuilder: (context, state) { assert(state.pathParameters["id"] != null); return SpotubePage( @@ -166,6 +199,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/playlist/:id", + name: PlaylistPage.name, pageBuilder: (context, state) { assert(state.extra is PlaylistSimple); return SpotubePage( @@ -177,6 +211,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/track/:id", + name: TrackPage.name, pageBuilder: (context, state) { final id = state.pathParameters["id"]!; return SpotubePage( @@ -186,12 +221,14 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/connect", + name: ConnectPage.name, pageBuilder: (context, state) => const SpotubePage( child: ConnectPage(), ), routes: [ GoRoute( path: "control", + name: ConnectControlPage.name, pageBuilder: (context, state) { return const SpotubePage( child: ConnectControlPage(), @@ -202,13 +239,66 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/profile", + name: ProfilePage.name, pageBuilder: (context, state) => const SpotubePage(child: ProfilePage()), + ), + GoRoute( + path: "/stats", + name: StatsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsPage(), + ), + routes: [ + GoRoute( + path: "minutes", + name: StatsMinutesPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsMinutesPage(), + ), + ), + GoRoute( + path: "streams", + name: StatsStreamsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsStreamsPage(), + ), + ), + GoRoute( + path: "fees", + name: StatsStreamFeesPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsStreamFeesPage(), + ), + ), + GoRoute( + path: "artists", + name: StatsArtistsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsArtistsPage(), + ), + ), + GoRoute( + path: "albums", + name: StatsAlbumsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsAlbumsPage(), + ), + ), + GoRoute( + path: "playlists", + name: StatsPlaylistsPage.name, + pageBuilder: (context, state) => const SpotubePage( + child: StatsPlaylistsPage(), + ), + ), + ], ) ], ), GoRoute( path: "/mini-player", + name: MiniLyricsPage.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage( child: MiniLyricsPage(prevSize: state.extra as Size), @@ -216,6 +306,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/getting-started", + name: GettingStarting.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( child: GettingStarting(), @@ -223,6 +314,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/login", + name: WebViewLogin.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage( child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), @@ -230,6 +322,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/login-tutorial", + name: LoginTutorial.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( child: LoginTutorial(), @@ -237,6 +330,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/lastfm-login", + name: LastFMLoginPage.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage(child: LastFMLoginPage()), diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart index 551d70d7..4f23c049 100644 --- a/lib/collections/side_bar_tiles.dart +++ b/lib/collections/side_bar_tiles.dart @@ -1,33 +1,82 @@ import 'package:flutter/material.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/library/library.dart'; +import 'package:spotube/pages/lyrics/lyrics.dart'; +import 'package:spotube/pages/search/search.dart'; +import 'package:spotube/pages/stats/stats.dart'; class SideBarTiles { final IconData icon; final String title; final String id; - SideBarTiles({required this.icon, required this.title, required this.id}); + final String name; + + SideBarTiles({ + required this.icon, + required this.title, + required this.id, + required this.name, + }); } List getSidebarTileList(AppLocalizations l10n) => [ - SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), - SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), SideBarTiles( - id: "library", icon: SpotubeIcons.library, title: l10n.library), - SideBarTiles(id: "lyrics", icon: SpotubeIcons.music, title: l10n.lyrics), - ]; - -List getNavbarTileList(AppLocalizations l10n) => [ - SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), - SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), + id: "browse", + name: HomePage.name, + icon: SpotubeIcons.home, + title: l10n.browse, + ), + SideBarTiles( + id: "search", + name: SearchPage.name, + icon: SpotubeIcons.search, + title: l10n.search, + ), SideBarTiles( id: "library", + name: LibraryPage.name, icon: SpotubeIcons.library, title: l10n.library, ), SideBarTiles( - id: "settings", - icon: SpotubeIcons.settings, - title: l10n.settings, - ) + id: "lyrics", + name: LyricsPage.name, + icon: SpotubeIcons.music, + title: l10n.lyrics, + ), + SideBarTiles( + id: "stats", + name: StatsPage.name, + icon: SpotubeIcons.chart, + title: l10n.stats, + ), + ]; + +List getNavbarTileList(AppLocalizations l10n) => [ + SideBarTiles( + id: "browse", + name: HomePage.name, + icon: SpotubeIcons.home, + title: l10n.browse, + ), + SideBarTiles( + id: "search", + name: SearchPage.name, + icon: SpotubeIcons.search, + title: l10n.search, + ), + SideBarTiles( + id: "library", + name: LibraryPage.name, + icon: SpotubeIcons.library, + title: l10n.library, + ), + SideBarTiles( + id: "stats", + name: StatsPage.name, + icon: SpotubeIcons.chart, + title: l10n.stats, + ), ]; diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 6de21284..a45e581e 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -121,4 +121,7 @@ abstract class SpotubeIcons { static const monitor = FeatherIcons.monitor; static const power = FeatherIcons.power; static const bluetooth = FeatherIcons.bluetooth; + static const chart = FeatherIcons.barChart2; + static const folderAdd = FeatherIcons.folderPlus; + static const folderRemove = FeatherIcons.folderMinus; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index a71fbf03..7212a574 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -9,7 +9,9 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/pages/album/album.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -32,6 +34,7 @@ class AlbumCard extends HookConsumerWidget { final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.read(playbackHistoryProvider.notifier); bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), @@ -62,7 +65,14 @@ class AlbumCard extends HookConsumerWidget { description: "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", onTap: () { - ServiceUtils.push(context, "/album/${album.id}", extra: album); + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: { + "id": album.id!, + }, + extra: album, + ); }, onPlaybuttonPressed: () async { updating.value = true; @@ -79,14 +89,15 @@ class AlbumCard extends HookConsumerWidget { if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.album( tracks: fetchedTracks, - collectionId: album.id!, + collection: album, ), ); } else { await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); } } finally { updating.value = false; @@ -104,6 +115,7 @@ class AlbumCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(album.id!); + historyNotifier.addAlbums([album]); if (context.mounted) { final snackbar = SnackBar( content: Text( diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index cc8485d5..9c1ee14a 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -9,6 +9,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -34,6 +35,10 @@ class ArtistCard extends HookConsumerWidget { final radius = BorderRadius.circular(15); + final bgColor = useBrightnessValue( + theme.colorScheme.surface, + theme.colorScheme.surfaceContainerHigh, + ); final double size = useBreakpointValue( xs: 130, sm: 130, @@ -45,12 +50,8 @@ class ArtistCard extends HookConsumerWidget { width: size, margin: const EdgeInsets.symmetric(vertical: 5), child: Material( - shadowColor: theme.colorScheme.background, - color: Color.lerp( - theme.colorScheme.surfaceVariant, - theme.colorScheme.surface, - useBrightnessValue(.9, .7), - ), + shadowColor: theme.colorScheme.surface, + color: bgColor, elevation: 3, shape: RoundedRectangleBorder( borderRadius: radius, @@ -63,7 +64,13 @@ class ArtistCard extends HookConsumerWidget { ), child: InkWell( onTap: () { - ServiceUtils.push(context, "/artist/${artist.id}"); + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: { + "id": artist.id!, + }, + ); }, borderRadius: radius, child: Padding( diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart index 3ac585df..f4888534 100644 --- a/lib/components/connect/connect_device.dart +++ b/lib/components/connect/connect_device.dart @@ -3,6 +3,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/connect/connect.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -22,7 +23,7 @@ class ConnectDeviceButton extends HookConsumerWidget { width: double.infinity, child: TextButton( onPressed: () { - ServiceUtils.push(context, "/connect"); + ServiceUtils.pushNamed(context, ConnectPage.name); }, style: FilledButton.styleFrom( shape: RoundedRectangleBorder( @@ -59,7 +60,7 @@ class ConnectDeviceButton extends HookConsumerWidget { clipBehavior: Clip.hardEdge, child: InkWell( onTap: () { - ServiceUtils.push(context, "/connect"); + ServiceUtils.pushNamed(context, ConnectPage.name); }, borderRadius: BorderRadius.circular(50), child: Ink( @@ -111,7 +112,7 @@ class ConnectDeviceButton extends HookConsumerWidget { foregroundColor: colorScheme.onPrimary, ), onPressed: () { - ServiceUtils.push(context, "/connect"); + ServiceUtils.pushNamed(context, ConnectPage.name); }, ), ), diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart index 2949fbae..6091829c 100644 --- a/lib/components/desktop_login/login_form.dart +++ b/lib/components/desktop_login/login_form.dart @@ -16,7 +16,6 @@ class TokenLoginForm extends HookConsumerWidget { Widget build(BuildContext context, ref) { final authenticationNotifier = ref.watch(authenticationProvider.notifier); final directCodeController = useTextEditingController(); - final mounted = useIsMounted(); final isLoading = useState(false); @@ -57,7 +56,7 @@ class TokenLoginForm extends HookConsumerWidget { await AuthenticationCredentials.fromCookie( cookieHeader), ); - if (mounted()) { + if (context.mounted) { onDone?.call(); } } finally { diff --git a/lib/components/home/sections/feed.dart b/lib/components/home/sections/feed.dart index 793cd2c3..f3f632ce 100644 --- a/lib/components/home/sections/feed.dart +++ b/lib/components/home/sections/feed.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/pages/home/feed/feed_section.dart'; import 'package:spotube/provider/spotify/views/home.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -41,8 +42,13 @@ class HomePageFeedSection extends HookConsumerWidget { child: TextButton.icon( label: const Text("Browse More"), icon: const Icon(SpotubeIcons.angleRight), - onPressed: () => - ServiceUtils.push(context, "/feeds/${section.uri}"), + onPressed: () => ServiceUtils.pushNamed( + context, + HomeFeedSectionPage.name, + pathParameters: { + "feedId": section.uri, + }, + ), ), ), ); diff --git a/lib/components/home/sections/friends.dart b/lib/components/home/sections/friends.dart index 35ec09b0..4ae802e6 100644 --- a/lib/components/home/sections/friends.dart +++ b/lib/components/home/sections/friends.dart @@ -1,12 +1,14 @@ import 'dart:ui'; 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:spotube/collections/fake.dart'; import 'package:spotube/components/home/sections/friends/friend_item.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HomePageFriendsSection extends HookConsumerWidget { @@ -14,6 +16,7 @@ class HomePageFriendsSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final auth = ref.watch(authenticationProvider); final friendsQuery = ref.watch(friendsProvider); final friends = friendsQuery.asData?.value.friends ?? FakeData.friends.friends; @@ -27,32 +30,36 @@ class HomePageFriendsSection extends HookConsumerWidget { xxl: 7, ); - final friendGroup = friends.fold>>( - [], - (previousValue, element) { - if (previousValue.isEmpty) { + final friendGroup = useMemoized( + () => friends.fold>>( + [], + (previousValue, element) { + if (previousValue.isEmpty) { + return [ + [element] + ]; + } + + final lastGroup = previousValue.last; + if (lastGroup.length < groupCount) { + return [ + ...previousValue.sublist(0, previousValue.length - 1), + [...lastGroup, element] + ]; + } + return [ + ...previousValue, [element] ]; - } - - final lastGroup = previousValue.last; - if (lastGroup.length < groupCount) { - return [ - ...previousValue.sublist(0, previousValue.length - 1), - [...lastGroup, element] - ]; - } - - return [ - ...previousValue, - [element] - ]; - }, + }, + ), + [friends, groupCount], ); if (friendsQuery.isLoading || - friendsQuery.asData?.value.friends.isEmpty == true) { + friendsQuery.asData?.value.friends.isEmpty == true || + auth == null) { return const SliverToBoxAdapter( child: SizedBox.shrink(), ); diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart index b883e2cc..096964a6 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/components/home/sections/friends/friend_item.dart @@ -6,6 +6,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/spotify_provider.dart'; class FriendItem extends HookConsumerWidget { @@ -27,7 +30,7 @@ class FriendItem extends HookConsumerWidget { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surfaceVariant.withOpacity(0.3), + color: colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(15), ), constraints: const BoxConstraints( @@ -57,7 +60,9 @@ class FriendItem extends HookConsumerWidget { text: friend.track.name, recognizer: TapGestureRecognizer() ..onTap = () { - context.push("/track/${friend.track.id}"); + context.pushNamed(TrackPage.name, pathParameters: { + "id": friend.track.id, + }); }, ), const TextSpan(text: " • "), @@ -71,8 +76,12 @@ class FriendItem extends HookConsumerWidget { text: " ${friend.track.artist.name}", recognizer: TapGestureRecognizer() ..onTap = () { - context.push( - "/artist/${friend.track.artist.id}", + context.pushNamed( + ArtistPage.name, + pathParameters: { + "id": friend.track.artist.id, + }, + extra: friend.track.artist, ); }, ), @@ -105,8 +114,11 @@ class FriendItem extends HookConsumerWidget { final album = await spotify.albums.get(friend.track.album.id); if (context.mounted) { - context.push( - "/album/${friend.track.album.id}", + context.pushNamed( + AlbumPage.name, + pathParameters: { + "id": friend.track.album.id, + }, extra: album, ); } diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index ac2644f0..62f462e2 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -13,6 +13,8 @@ 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/pages/home/genres/genre_playlists.dart'; +import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HomeGenresSection extends HookConsumerWidget { @@ -50,11 +52,11 @@ class HomeGenresSection extends HookConsumerWidget { textDirection: TextDirection.rtl, child: TextButton.icon( onPressed: () { - context.push('/genres'); + context.pushNamed(GenrePage.name); }, icon: const Icon(SpotubeIcons.angleRight), label: Text( - "Browse All", + context.l10n.browse_all, style: textTheme.bodyMedium?.copyWith( color: colorScheme.secondary, ), @@ -110,7 +112,13 @@ class HomeGenresSection extends HookConsumerWidget { return InkWell( onTap: () { - context.push('/genre/${category.id}', extra: category); + context.pushNamed( + GenrePlaylistsPage.name, + pathParameters: { + "categoryId": category.id!, + }, + extra: category, + ); }, borderRadius: BorderRadius.circular(8), child: Ink( @@ -126,7 +134,7 @@ class HomeGenresSection extends HookConsumerWidget { child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), - color: colorScheme.surfaceVariant, + color: colorScheme.surfaceContainerHighest, gradient: categoriesQuery.isLoading ? null : gradient, ), padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/components/home/sections/recent.dart b/lib/components/home/sections/recent.dart new file mode 100644 index 00000000..0fc5fadf --- /dev/null +++ b/lib/components/home/sections/recent.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/provider/history/recent.dart'; +import 'package:spotube/provider/history/state.dart'; + +class HomeRecentlyPlayedSection extends HookConsumerWidget { + const HomeRecentlyPlayedSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final history = ref.watch(recentlyPlayedItems); + + if (history.isEmpty) { + return const SizedBox(); + } + + return HorizontalPlaybuttonCardView( + title: const Text('Recently Played'), + items: [ + for (final item in history) + if (item is PlaybackHistoryPlaylist) + item.playlist + else if (item is PlaybackHistoryAlbum) + item.album + ], + hasNextPage: false, + isLoadingNextPage: false, + onFetchMore: () {}, + ); + } +} diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/components/library/local_folder/local_folder_item.dart new file mode 100644 index 00000000..6220a967 --- /dev/null +++ b/lib/components/library/local_folder/local_folder_item.dart @@ -0,0 +1,199 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:path/path.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/extensions/image.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/pages/library/local_folder.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +class LocalFolderItem extends HookConsumerWidget { + final String folder; + const LocalFolderItem({super.key, required this.folder}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme) = Theme.of(context); + final mediaQuery = MediaQuery.of(context); + + final lerpValue = useBrightnessValue(.9, .7); + + final downloadFolder = + ref.watch(userPreferencesProvider.select((s) => s.downloadLocation)); + + final isDownloadFolder = folder == downloadFolder; + + final Uri(:pathSegments) = Uri.parse( + folder + .replaceFirst(RegExp(r'^/Volumes/[^/]+/Users/'), "") + .replaceFirst(r'C:\Users\', "") + .replaceFirst(r'/home/', ""), + ); + + // if length > 5, we ... all the middle segments after 2 and the last 2 + final segments = pathSegments.length > 5 + ? [ + ...pathSegments.take(2), + "...", + ...pathSegments.skip(pathSegments.length - 3).toList() + ..removeLast(), + ] + : pathSegments.take(pathSegments.length - 1).toList(); + + final trackSnapshot = ref.watch( + localTracksProvider.select( + (s) => s.whenData((tracks) => tracks[folder]?.take(4).toList()), + ), + ); + + final tracks = trackSnapshot.value ?? []; + + return InkWell( + onTap: () { + context.goNamed( + LocalLibraryPage.name, + queryParameters: { + if (isDownloadFolder) "downloads": "true", + }, + extra: folder, + ); + }, + borderRadius: BorderRadius.circular(8), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Color.lerp( + colorScheme.surfaceContainerHighest, + colorScheme.surface, + lerpValue, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (tracks.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + SpotubeIcons.folder, + size: mediaQuery.smAndDown + ? 95 + : mediaQuery.mdAndDown + ? 100 + : 142, + ), + ), + ) + else + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: max((tracks.length / 2).ceil(), 2), + ), + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + return UniversalImage( + path: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ); + }, + ), + ), + const Gap(8), + Stack( + children: [ + Center( + child: Text( + isDownloadFolder + ? context.l10n.downloads + : basename(folder), + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ), + if (!isDownloadFolder) + Align( + alignment: Alignment.topRight, + child: PopupMenuButton( + child: const Padding( + padding: EdgeInsets.all(3), + child: Icon(Icons.more_vert), + ), + itemBuilder: (context) { + return [ + PopupMenuItem( + child: ListTile( + leading: const Icon(SpotubeIcons.folderRemove), + iconColor: colorScheme.error, + title: + Text(context.l10n.remove_library_location), + onTap: () { + final libraryLocations = ref + .read(userPreferencesProvider) + .localLibraryLocation; + ref + .read(userPreferencesProvider.notifier) + .setLocalLibraryLocation( + libraryLocations + .where((e) => e != folder) + .toList(), + ); + }, + ), + ) + ]; + }, + ), + ), + ], + ), + const Spacer(), + Wrap( + spacing: 2, + runSpacing: 2, + children: [ + for (final MapEntry(key: index, value: segment) + in segments.asMap().entries) + Text.rich( + TextSpan( + children: [ + if (index != 0) + TextSpan( + text: "/ ", + style: TextStyle(color: colorScheme.primary), + ), + TextSpan(text: segment), + ], + ), + style: TextStyle( + fontSize: 10, + color: colorScheme.tertiary, + ), + ), + ], + ), + const Spacer(), + ], + ), + ), + ), + ); + } +} diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/components/library/playlist_generate/multi_select_field.dart index e54fc2ba..d8e0506d 100644 --- a/lib/components/library/playlist_generate/multi_select_field.dart +++ b/lib/components/library/playlist_generate/multi_select_field.dart @@ -71,7 +71,7 @@ class MultiSelectField extends HookWidget { : theme.colorScheme.onSurface.withOpacity(0.1), ), ), - mouseCursor: MaterialStateMouseCursor.textable, + mouseCursor: WidgetStateMouseCursor.textable, onPressed: !enabled ? null : () async { diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index a7b2102b..c0d63380 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -1,52 +1,18 @@ -import 'dart:io'; - -import 'package:catcher_2/catcher_2.dart'; -import 'package:flutter/foundation.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:collection/collection.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -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/sort_tracks_dropdown.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; -import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/components/library/local_folder/local_folder_item.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; +import 'package:spotube/utils/platform.dart'; // ignore: depend_on_referenced_packages -import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; - -const supportedAudioTypes = [ - "audio/webm", - "audio/ogg", - "audio/mpeg", - "audio/mp4", - "audio/opus", - "audio/wav", - "audio/aac", -]; - -const imgMimeToExt = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/webp": ".webp", - "image/gif": ".gif", -}; enum SortBy { none, @@ -59,273 +25,77 @@ enum SortBy { album, } -final localTracksProvider = FutureProvider>((ref) async { - try { - if (kIsWeb) return []; - final downloadLocation = ref.watch( - userPreferencesProvider.select((s) => s.downloadLocation), - ); - if (downloadLocation.isEmpty) return []; - final downloadDir = Directory(downloadLocation); - if (!await downloadDir.exists()) { - await downloadDir.create(recursive: true); - return []; - } - final entities = downloadDir.listSync(recursive: true); - - final filesWithMetadata = (await Future.wait( - entities.map((e) => File(e.path)).where((file) { - final mimetype = lookupMimeType(file.path); - return mimetype != null && supportedAudioTypes.contains(mimetype); - }).map( - (file) async { - try { - final metadata = await MetadataGod.readMetadata(file: file.path); - - final imageFile = File(join( - (await getTemporaryDirectory()).path, - "spotube", - basenameWithoutExtension(file.path) + - imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, - )); - if (!await imageFile.exists() && metadata.picture != null) { - await imageFile.create(recursive: true); - await imageFile.writeAsBytes( - metadata.picture?.data ?? [], - mode: FileMode.writeOnly, - ); - } - - return {"metadata": metadata, "file": file, "art": imageFile.path}; - } catch (e, stack) { - if (e is FfiException) { - return {"file": file}; - } - Catcher2.reportCheckedError(e, stack); - return {}; - } - }, - ), - )) - .where((e) => e.isNotEmpty) - .toList(); - - final tracks = filesWithMetadata - .map( - (fileWithMetadata) => LocalTrack.fromTrack( - track: Track().fromFile( - fileWithMetadata["file"], - metadata: fileWithMetadata["metadata"], - art: fileWithMetadata["art"], - ), - path: fileWithMetadata["file"].path, - ), - ) - .toList(); - - return tracks; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - return []; - } -}); - class UserLocalTracks extends HookConsumerWidget { const UserLocalTracks({super.key}); - Future playLocalTracks( - WidgetRef ref, - List tracks, { - LocalTrack? currentTrack, - }) async { - final playlist = ref.read(proxyPlaylistProvider); - final playback = ref.read(proxyPlaylistProvider.notifier); - currentTrack ??= tracks.first; - final isPlaylistPlaying = playlist.containsTracks(tracks); - if (!isPlaylistPlaying) { - await playback.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } - @override Widget build(BuildContext context, ref) { - final sortBy = useState(SortBy.none); - final playlist = ref.watch(proxyPlaylistProvider); - final trackSnapshot = ref.watch(localTracksProvider); - final isPlaylistPlaying = - playlist.containsTracks(trackSnapshot.asData?.value ?? []); + final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); + final preferences = ref.watch(userPreferencesProvider); - final searchController = useTextEditingController(); - useValueListenable(searchController); - final searchFocus = useFocusNode(); - final isFiltering = useState(false); + final addLocalLibraryLocation = useCallback(() async { + if (kIsMobile || kIsMacOS) { + final dirStr = await FilePicker.platform.getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + if (preferences.localLibraryLocation.contains(dirStr)) return; + preferencesNotifier.setLocalLibraryLocation( + [...preferences.localLibraryLocation, dirStr]); + } else { + String? dirStr = await getDirectoryPath( + initialDirectory: preferences.downloadLocation, + ); + if (dirStr == null) return; + if (preferences.localLibraryLocation.contains(dirStr)) return; + preferencesNotifier.setLocalLibraryLocation( + [...preferences.localLibraryLocation, dirStr]); + } + }, [preferences.localLibraryLocation]); - final controller = useScrollController(); + // This is just to pre-load the tracks. + // For now, this gets all of them. + ref.watch(localTracksProvider); - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const SizedBox(width: 5), - FilledButton( - onPressed: trackSnapshot.asData?.value != null - ? () async { - if (trackSnapshot.asData?.value.isNotEmpty == true) { - if (!isPlaylistPlaying) { - await playLocalTracks( - ref, - trackSnapshot.asData!.value, - ); - } - } - } - : null, - child: Row( - children: [ - Text(context.l10n.play), - Icon( - isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, - ) - ], - ), - ), - const Spacer(), - ExpandableSearchButton( - isFiltering: isFiltering.value, - onPressed: (value) => isFiltering.value = value, - searchFocus: searchFocus, - ), - const SizedBox(width: 10), - SortTracksDropdown( - value: sortBy.value, - onChanged: (value) { - sortBy.value = value; - }, - ), - const SizedBox(width: 5), - FilledButton( - child: const Icon(SpotubeIcons.refresh), - onPressed: () { - ref.invalidate(localTracksProvider); - }, - ) - ], - ), - ), - ExpandableSearchField( - searchController: searchController, - searchFocus: searchFocus, - isFiltering: isFiltering.value, - onChangeFiltering: (value) => isFiltering.value = value, - ), - trackSnapshot.when( - data: (tracks) { - final sortedTracks = useMemoized(() { - return ServiceUtils.sortTracks(tracks, sortBy.value); - }, [sortBy.value, tracks]); - - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return sortedTracks; - } - return sortedTracks - .map((e) => ( - weightedRatio( - "${e.name} - ${e.artists?.asString() ?? ""}", - searchController.text, - ), - e, - )) - .toList() - .sorted( - (a, b) => b.$1.compareTo(a.$1), - ) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList() - .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 { - ref.invalidate(localTracksProvider); - }, - child: InterScrollbar( - controller: controller, - 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( - playlist: playlist, - track: FakeData.track, - index: index, - ); - } - - final track = filteredTracks[index]; - return TrackTile( - index: index, - playlist: playlist, - track: track, - userPlaylist: false, - onTap: () async { - await playLocalTracks( - ref, - sortedTracks, - currentTrack: track, - ); - }, - ); - }, - ), - ), - ), - ), - ); - }, - loading: () => Expanded( - child: Skeletonizer( - enabled: true, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) => TrackTile( - track: FakeData.track, - index: index, - playlist: playlist, - ), + return LayoutBuilder(builder: (context, constrains) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(SpotubeIcons.folderAdd), + label: Text(context.l10n.add_library_location), + onPressed: addLocalLibraryLocation, ), ), - ), - error: (error, stackTrace) => - Text(error.toString() + stackTrace.toString()), - ) - ], - ); + const Gap(8), + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + mainAxisExtent: constrains.isXs + ? 210 + : constrains.mdAndDown + ? 280 + : 250, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: preferences.localLibraryLocation.length + 1, + itemBuilder: (context, index) { + return LocalFolderItem( + folder: index == 0 + ? preferences.downloadLocation + : preferences.localLibraryLocation[index - 1], + ); + }, + ), + ), + ], + ), + ); + }); } } diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 914d7bc9..1665b3dd 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -122,7 +122,8 @@ class PlayerQueue extends HookConsumerWidget { top: 5.0, ), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), + color: + theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), borderRadius: borderRadius, ), child: CallbackShortcuts( diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 99b7b430..0575d8eb 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -208,7 +208,8 @@ class SiblingTracksSheet extends HookConsumerWidget { : mediaQuery.size.height * .6, decoration: BoxDecoration( borderRadius: borderRadius, - color: theme.colorScheme.surfaceVariant.withOpacity(.5), + color: + theme.colorScheme.surfaceContainerHighest.withOpacity(.5), ), child: Scaffold( backgroundColor: Colors.transparent, diff --git a/lib/components/player/volume_slider.dart b/lib/components/player/volume_slider.dart index 102bbef6..8483143b 100644 --- a/lib/components/player/volume_slider.dart +++ b/lib/components/player/volume_slider.dart @@ -1,6 +1,5 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -31,11 +30,17 @@ class VolumeSlider extends HookConsumerWidget { } } }, - child: Slider( - min: 0, - max: 1, - value: value, - onChanged: onChanged, + child: SliderTheme( + data: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + ), + child: Slider( + min: 0, + max: 1, + label: (value * 100).toStringAsFixed(0), + value: value, + onChanged: onChanged, + ), ), ); return Row( diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index ae6f20e5..9f26f739 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -6,7 +6,9 @@ import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -22,6 +24,8 @@ class PlaylistCard extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlistQueue = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.read(playbackHistoryProvider.notifier); + final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; bool isPlaylistPlaying = useMemoized( @@ -32,12 +36,23 @@ class PlaylistCard extends HookConsumerWidget { final updating = useState(false); final me = ref.watch(meProvider); - Future> fetchAllTracks() async { + Future> fetchInitialTracks() async { if (playlist.id == 'user-liked-tracks') { return await ref.read(likedTracksProvider.future); } - await ref.read(playlistTracksProvider(playlist.id!).future); + final result = + await ref.read(playlistTracksProvider(playlist.id!).future); + + return result.items; + } + + Future> fetchAllTracks() async { + final initialTracks = await fetchInitialTracks(); + + if (playlist.id == 'user-liked-tracks') { + return initialTracks; + } return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); } @@ -55,9 +70,12 @@ class PlaylistCard extends HookConsumerWidget { isOwner: playlist.owner?.id == me.asData?.value.id && me.asData?.value.id != null, onTap: () { - ServiceUtils.push( + ServiceUtils.pushNamed( context, - "/playlist/${playlist.id}", + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, extra: playlist, ); }, @@ -70,22 +88,29 @@ class PlaylistCard extends HookConsumerWidget { return audioPlayer.resume(); } - List fetchedTracks = await fetchAllTracks(); + final fetchedInitialTracks = await fetchInitialTracks(); - if (fetchedTracks.isEmpty || !context.mounted) return; + if (fetchedInitialTracks.isEmpty || !context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); + final allTracks = await fetchAllTracks(); await remotePlayback.load( - WebSocketLoadEventData( - tracks: fetchedTracks, - collectionId: playlist.id!, + WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: playlist, ), ); } else { - await playlistNotifier.load(fetchedTracks, autoPlay: true); + await playlistNotifier.load(fetchedInitialTracks, autoPlay: true); playlistNotifier.addCollection(playlist.id!); + historyNotifier.addPlaylists([playlist]); + + final allTracks = await fetchAllTracks(); + + await playlistNotifier + .addTracks(allTracks.sublist(fetchedInitialTracks.length)); } } finally { if (context.mounted) { @@ -98,20 +123,22 @@ class PlaylistCard extends HookConsumerWidget { try { if (isPlaylistPlaying) return; - final fetchedTracks = await fetchAllTracks(); + final fetchedInitialTracks = await fetchAllTracks(); - if (fetchedTracks.isEmpty) return; + if (fetchedInitialTracks.isEmpty) return; - playlistNotifier.addTracks(fetchedTracks); + playlistNotifier.addTracks(fetchedInitialTracks); playlistNotifier.addCollection(playlist.id!); + historyNotifier.addPlaylists([playlist]); if (context.mounted) { final snackbar = SnackBar( - content: Text("Added ${fetchedTracks.length} tracks to queue"), + content: + Text("Added ${fetchedInitialTracks.length} tracks to queue"), action: SnackBarAction( label: "Undo", onPressed: () { playlistNotifier - .removeTracks(fetchedTracks.map((e) => e.id!)); + .removeTracks(fetchedInitialTracks.map((e) => e.id!)); }, ), ); diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 06250131..b99318df 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -1,6 +1,5 @@ import 'dart:ui'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -15,7 +14,6 @@ import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; @@ -24,6 +22,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.dart'; class BottomPlayer extends HookConsumerWidget { BottomPlayer({super.key}); @@ -49,12 +48,6 @@ class BottomPlayer extends HookConsumerWidget { ); final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceVariant; - - final bgColor = useBrightnessValue( - Color.lerp(bg, Colors.white, 0.7), - Color.lerp(bg, Colors.black, 0.45)!, - ); // returning an empty non spacious Container as the overlay will take // place in the global overlay stack aka [_entries] @@ -67,7 +60,9 @@ class BottomPlayer extends HookConsumerWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: DecoratedBox( - decoration: BoxDecoration(color: bgColor?.withOpacity(0.8)), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainer.withOpacity(.8), + ), child: Material( type: MaterialType.transparency, textStyle: theme.textTheme.bodyMedium!, @@ -95,19 +90,19 @@ class BottomPlayer extends HookConsumerWidget { tooltip: context.l10n.mini_player, icon: const Icon(SpotubeIcons.miniPlayer), onPressed: () async { - final prevSize = - await DesktopTools.window.getSize(); - await DesktopTools.window.setMinimumSize( + if (!kIsDesktop) return; + + final prevSize = await windowManager.getSize(); + await windowManager.setMinimumSize( const Size(300, 300), ); - await DesktopTools.window.setAlwaysOnTop(true); + await windowManager.setAlwaysOnTop(true); if (!kIsLinux) { - await DesktopTools.window.setHasShadow(false); + await windowManager.setHasShadow(false); } - await DesktopTools.window + await windowManager .setAlignment(Alignment.topRight); - await DesktopTools.window - .setSize(const Size(400, 500)); + await windowManager.setSize(const Size(400, 500)); await Future.delayed( const Duration(milliseconds: 100), () async { diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index a100ca8e..4fa14021 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -14,8 +14,9 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; +import 'package:spotube/pages/profile/profile.dart'; +import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -26,13 +27,9 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class Sidebar extends HookConsumerWidget { - final int? selectedIndex; - final void Function(int) onSelectedIndexChanged; final Widget child; const Sidebar({ - required this.selectedIndex, - required this.onSelectedIndexChanged, required this.child, super.key, }); @@ -47,12 +44,9 @@ class Sidebar extends HookConsumerWidget { ); } - static void goToSettings(BuildContext context) { - GoRouter.of(context).go("/settings"); - } - @override Widget build(BuildContext context, WidgetRef ref) { + final routerState = GoRouterState.of(context); final mediaQuery = MediaQuery.of(context); final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; @@ -60,41 +54,22 @@ class Sidebar extends HookConsumerWidget { final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - final controller = useSidebarXController( - selectedIndex: selectedIndex ?? 0, - extended: mediaQuery.lgAndUp, - ); - - final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceVariant; - - final bgColor = useBrightnessValue( - Color.lerp(bg, Colors.white, 0.7), - Color.lerp(bg, Colors.black, 0.45)!, - ); - final sidebarTileList = useMemoized( () => getSidebarTileList(context.l10n), [context.l10n], ); - useEffect(() { - if (controller.selectedIndex != selectedIndex && selectedIndex != null) { - controller.selectIndex(selectedIndex!); - } - return null; - }, [selectedIndex]); + final selectedIndex = sidebarTileList.indexWhere( + (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, + ); - useEffect(() { - void listener() { - onSelectedIndexChanged(controller.selectedIndex); - } + final controller = useSidebarXController( + selectedIndex: selectedIndex, + extended: mediaQuery.lgAndUp, + ); - controller.addListener(listener); - return () { - controller.removeListener(listener); - }; - }, [controller]); + final theme = Theme.of(context); + final bg = theme.colorScheme.surfaceContainer; useEffect(() { if (!context.mounted) return; @@ -106,6 +81,13 @@ class Sidebar extends HookConsumerWidget { return null; }, [mediaQuery, controller]); + useEffect(() { + if (controller.selectedIndex != selectedIndex) { + controller.selectIndex(selectedIndex); + } + return null; + }, [selectedIndex]); + if (layoutMode == LayoutMode.compact || (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { return Scaffold(body: child); @@ -119,23 +101,28 @@ class Sidebar extends HookConsumerWidget { items: sidebarTileList.mapIndexed( (index, e) { return SidebarXItem( - iconWidget: Badge( - backgroundColor: theme.colorScheme.primary, - isLabelVisible: e.title == "Library" && downloadCount > 0, - label: Text( - downloadCount.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 10, + onTap: () { + context.goNamed(e.name); + }, + iconBuilder: (selected, hovered) { + return Badge( + backgroundColor: theme.colorScheme.primary, + isLabelVisible: e.title == "Library" && downloadCount > 0, + label: Text( + downloadCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + ), ), - ), - child: Icon( - e.icon, - color: selectedIndex == index - ? theme.colorScheme.primary - : null, - ), - ), + child: Icon( + e.icon, + color: selected || hovered + ? theme.colorScheme.primary + : null, + ), + ); + }, label: e.title, ); }, @@ -166,7 +153,7 @@ class Sidebar extends HookConsumerWidget { ), padding: const EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( - color: bgColor?.withOpacity(0.8), + color: bg, borderRadius: const BorderRadius.only( topRight: Radius.circular(10), bottomRight: Radius.circular(10), @@ -257,7 +244,7 @@ class SidebarFooter extends HookConsumerWidget { if (mediaQuery.mdAndDown) { return IconButton( icon: const Icon(SpotubeIcons.settings), - onPressed: () => Sidebar.goToSettings(context), + onPressed: () => ServiceUtils.navigateNamed(context, SettingsPage.name), ); } @@ -278,7 +265,7 @@ class SidebarFooter extends HookConsumerWidget { Flexible( child: InkWell( onTap: () { - ServiceUtils.push(context, "/profile"); + ServiceUtils.pushNamed(context, ProfilePage.name); }, borderRadius: BorderRadius.circular(30), child: Row( @@ -310,7 +297,7 @@ class SidebarFooter extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.settings), onPressed: () { - Sidebar.goToSettings(context); + ServiceUtils.pushNamed(context, SettingsPage.name); }, ), ], diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 489399e5..3d0c7c75 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -3,55 +3,54 @@ import 'dart:ui'; import 'package:curved_navigation_bar/curved_navigation_bar.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:spotube/collections/side_bar_tiles.dart'; -import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/utils/service_utils.dart'; final navigationPanelHeight = StateProvider((ref) => 50); class SpotubeNavigationBar extends HookConsumerWidget { - final int? selectedIndex; - final void Function(int) onSelectedIndexChanged; - const SpotubeNavigationBar({ - required this.selectedIndex, - required this.onSelectedIndexChanged, super.key, }); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); + final routerState = GoRouterState.of(context); + final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; final mediaQuery = MediaQuery.of(context); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); - final insideSelectedIndex = useState(selectedIndex ?? 0); - final buttonColor = useBrightnessValue( theme.colorScheme.inversePrimary, theme.colorScheme.primary.withOpacity(0.2), ); - final navbarTileList = - useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]); + final navbarTileList = useMemoized( + () => getNavbarTileList(context.l10n), + [context.l10n], + ); final panelHeight = ref.watch(navigationPanelHeight); - useEffect(() { - if (selectedIndex != null) { - insideSelectedIndex.value = selectedIndex!; - } - return null; - }, [selectedIndex]); + final selectedIndex = useMemoized(() { + final index = navbarTileList.indexWhere( + (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, + ); + + return index == -1 ? 0 : index; + }, [navbarTileList, routerState.matchedLocation]); if (layoutMode == LayoutMode.extended || (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || @@ -69,7 +68,7 @@ class SpotubeNavigationBar extends HookConsumerWidget { backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.72), buttonBackgroundColor: buttonColor, - color: theme.colorScheme.background, + color: theme.colorScheme.surface, height: panelHeight, animationDuration: const Duration(milliseconds: 350), items: navbarTileList.map( @@ -91,14 +90,9 @@ class SpotubeNavigationBar extends HookConsumerWidget { }); }, ).toList(), - index: insideSelectedIndex.value, + index: selectedIndex, onTap: (i) { - insideSelectedIndex.value = i; - if (navbarTileList[i].id == "settings") { - Sidebar.goToSettings(context); - return; - } - onSelectedIndexChanged(i); + ServiceUtils.navigateNamed(context, navbarTileList[i].name); }, ), ), diff --git a/lib/components/root/update_dialog.dart b/lib/components/root/update_dialog.dart new file mode 100644 index 00000000..e15903c6 --- /dev/null +++ b/lib/components/root/update_dialog.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:version/version.dart'; + +class RootAppUpdateDialog extends StatelessWidget { + final Version? version; + final int? nightlyBuildNum; + + const RootAppUpdateDialog({super.key, this.version}) : nightlyBuildNum = null; + const RootAppUpdateDialog.nightly({super.key, required this.nightlyBuildNum}) + : version = null; + + @override + Widget build(BuildContext context) { + const url = "https://spotube.krtirtho.dev/downloads"; + const nightlyUrl = "https://spotube.krtirtho.dev/downloads/nightly"; + return AlertDialog( + title: const Text("Spotube has an update"), + actions: [ + FilledButton( + child: const Text("Download Now"), + onPressed: () => launchUrlString( + nightlyBuildNum != null ? nightlyUrl : url, + mode: LaunchMode.externalApplication, + ), + ), + ], + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + nightlyBuildNum != null + ? "Spotube Nightly $nightlyBuildNum has been released" + : "Spotube v$version has been released", + ), + if (nightlyBuildNum == null) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Read the latest "), + AnchorButton( + "release notes", + style: const TextStyle(color: Colors.blue), + onTap: () => launchUrlString( + url, + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart index 8d098375..579f5a29 100644 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ b/lib/components/settings/color_scheme_picker_dialog.dart @@ -179,9 +179,9 @@ class ColorTile extends StatelessWidget { colorScheme.primaryContainer, colorScheme.secondary, colorScheme.secondaryContainer, - colorScheme.background, colorScheme.surface, - colorScheme.surfaceVariant, + colorScheme.surface, + colorScheme.surfaceContainerHighest, colorScheme.onPrimary, colorScheme.onSurface, ]; diff --git a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart index 21f56a22..ce7d3b8c 100644 --- a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart @@ -187,7 +187,7 @@ class AdaptivePopSheetList extends StatelessWidget { icon: icon ?? const Icon(SpotubeIcons.moreVertical), tooltip: tooltip, style: theme.iconButtonTheme.style?.copyWith( - shape: MaterialStatePropertyAll( + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: borderRadius, ), diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/shared/fallbacks/anonymous_fallback.dart index 2f06b0b6..5ced6bb6 100644 --- a/lib/components/shared/fallbacks/anonymous_fallback.dart +++ b/lib/components/shared/fallbacks/anonymous_fallback.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -25,7 +26,7 @@ class AnonymousFallback extends ConsumerWidget { const SizedBox(height: 10), FilledButton( child: Text(context.l10n.login_with_spotify), - onPressed: () => ServiceUtils.push(context, "/settings"), + onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name), ) ], ), diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index e142cb35..291950bb 100644 --- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -96,7 +96,7 @@ class HorizontalPlaybuttonCardView extends HookWidget { return switch (item) { PlaylistSimple() => PlaylistCard(item as PlaylistSimple), - AlbumSimple() => AlbumCard(item as Album), + AlbumSimple() => AlbumCard(item as AlbumSimple), Artist() => Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0), diff --git a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart index 2b3ce319..8a86b643 100644 --- a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart +++ b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart @@ -1,7 +1,7 @@ import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/utils/platform.dart'; class InterScrollbar extends HookWidget { final Widget child; @@ -15,7 +15,7 @@ class InterScrollbar extends HookWidget { @override Widget build(BuildContext context) { - if (DesktopTools.platform.isDesktop) return child; + if (kIsDesktop) return child; return DraggableScrollbar.semicircle( controller: controller, diff --git a/lib/components/shared/links/anchor_button.dart b/lib/components/shared/links/anchor_button.dart index d78bbf96..c6f0b889 100644 --- a/lib/components/shared/links/anchor_button.dart +++ b/lib/components/shared/links/anchor_button.dart @@ -29,7 +29,7 @@ class AnchorButton extends HookWidget { onTapUp: (event) => tap.value = false, onTap: onTap, child: MouseRegion( - cursor: MaterialStateMouseCursor.clickable, + cursor: WidgetStateMouseCursor.clickable, child: Text( text, style: style.copyWith( diff --git a/lib/components/shared/links/artist_link.dart b/lib/components/shared/links/artist_link.dart index af8b186a..5236a061 100644 --- a/lib/components/shared/links/artist_link.dart +++ b/lib/components/shared/links/artist_link.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/utils/service_utils.dart'; class ArtistLink extends StatelessWidget { @@ -40,9 +41,12 @@ class ArtistLink extends StatelessWidget { if (onRouteChange != null) { onRouteChange?.call("/artist/${artist.value.id}"); } else { - ServiceUtils.push( + ServiceUtils.pushNamed( context, - "/artist/${artist.value.id}", + ArtistPage.name, + pathParameters: { + "id": artist.value.id!, + }, ); } }, diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index 37daefa9..573c7c47 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -7,7 +7,8 @@ import 'package:titlebar_buttons/titlebar_buttons.dart'; import 'dart:math'; import 'package:flutter/foundation.dart' show kIsWeb; import 'dart:io' show Platform; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + +import 'package:window_manager/window_manager.dart'; class PageWindowTitleBar extends StatefulHookConsumerWidget implements PreferredSizeWidget { @@ -89,7 +90,7 @@ class _PageWindowTitleBarState extends ConsumerState { final systemTitleBar = ref.read(userPreferencesProvider.select((s) => s.systemTitleBar)); if (kIsDesktop && !systemTitleBar) { - DesktopTools.window.startDragging(); + windowManager.startDragging(); } } @@ -107,11 +108,7 @@ class _PageWindowTitleBarState extends ConsumerState { return SliverPadding( padding: EdgeInsets.only( - left: DesktopTools.platform.isMacOS && - hasFullscreen && - hasLeadingOrCanPop - ? 65 - : 0, + left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, ), sliver: SliverAppBar( leading: widget.leading, @@ -149,11 +146,7 @@ class _PageWindowTitleBarState extends ConsumerState { onVerticalDragStart: onDrag, child: Padding( padding: EdgeInsets.only( - left: DesktopTools.platform.isMacOS && - hasFullscreen && - hasLeadingOrCanPop - ? 65 - : 0, + left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, ), child: AppBar( leading: widget.leading, @@ -172,6 +165,10 @@ class _PageWindowTitleBarState extends ConsumerState { toolbarTextStyle: widget.toolbarTextStyle, titleTextStyle: widget.titleTextStyle, title: widget.title, + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + forceMaterialTransparency: true, + elevation: 0, ), ), ); @@ -193,12 +190,12 @@ class WindowTitleBarButtons extends HookConsumerWidget { const type = ThemeType.auto; Future onClose() async { - await DesktopTools.window.close(); + await windowManager.close(); } useEffect(() { if (kIsDesktop) { - DesktopTools.window.isMaximized().then((value) { + windowManager.isMaximized().then((value) { isMaximized.value = value; }); } @@ -213,16 +210,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { final theme = Theme.of(context); final colors = WindowButtonColors( normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onBackground, - mouseOver: theme.colorScheme.onBackground.withOpacity(0.1), - mouseDown: theme.colorScheme.onBackground.withOpacity(0.2), - iconMouseOver: theme.colorScheme.onBackground, - iconMouseDown: theme.colorScheme.onBackground, + iconNormal: foregroundColor ?? theme.colorScheme.onSurface, + mouseOver: theme.colorScheme.onSurface.withOpacity(0.1), + mouseDown: theme.colorScheme.onSurface.withOpacity(0.2), + iconMouseOver: theme.colorScheme.onSurface, + iconMouseDown: theme.colorScheme.onSurface, ); final closeColors = WindowButtonColors( normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onBackground, + iconNormal: foregroundColor ?? theme.colorScheme.onSurface, mouseOver: Colors.red, mouseDown: Colors.red[800]!, iconMouseOver: Colors.white, @@ -235,14 +232,14 @@ class WindowTitleBarButtons extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ MinimizeWindowButton( - onPressed: DesktopTools.window.minimize, + onPressed: windowManager.minimize, colors: colors, ), if (isMaximized.value != true) MaximizeWindowButton( colors: colors, onPressed: () { - DesktopTools.window.maximize(); + windowManager.maximize(); isMaximized.value = true; }, ) @@ -250,7 +247,7 @@ class WindowTitleBarButtons extends HookConsumerWidget { RestoreWindowButton( colors: colors, onPressed: () { - DesktopTools.window.unmaximize(); + windowManager.unmaximize(); isMaximized.value = false; }, ), @@ -270,16 +267,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { children: [ DecoratedMinimizeButton( type: type, - onPressed: DesktopTools.window.minimize, + onPressed: windowManager.minimize, ), DecoratedMaximizeButton( type: type, onPressed: () async { - if (await DesktopTools.window.isMaximized()) { - await DesktopTools.window.unmaximize(); + if (await windowManager.isMaximized()) { + await windowManager.unmaximize(); isMaximized.value = false; } else { - await DesktopTools.window.maximize(); + await windowManager.maximize(); isMaximized.value = true; } }, diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index 80a27eb0..807628b3 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -53,6 +53,10 @@ class PlaybuttonCard extends HookWidget { final mediaQuery = MediaQuery.of(context); final radius = BorderRadius.circular(15); + final bgColor = useBrightnessValue( + theme.colorScheme.surface, + theme.colorScheme.surfaceContainerHigh, + ); final double size = useBreakpointValue( xs: 130, sm: 130, @@ -72,13 +76,9 @@ class PlaybuttonCard extends HookWidget { constraints: BoxConstraints(maxWidth: size), margin: margin, child: Material( - color: Color.lerp( - theme.colorScheme.surfaceVariant, - theme.colorScheme.surface, - useBrightnessValue(.9, .7), - ), + color: bgColor, borderRadius: radius, - shadowColor: theme.colorScheme.background, + shadowColor: theme.colorScheme.surface, elevation: 3, child: InkWell( mouseCursor: SystemMouseCursors.click, @@ -158,7 +158,7 @@ class PlaybuttonCard extends HookWidget { Skeleton.keep( child: IconButton( style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.background, + backgroundColor: theme.colorScheme.surface, foregroundColor: theme.colorScheme.primary, minimumSize: const Size.square(10), ), diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index 017f04aa..c245e5f4 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -5,7 +5,8 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { final List tabs; - const ThemedButtonsTabBar({super.key, required this.tabs}); + final TabController? controller; + const ThemedButtonsTabBar({super.key, required this.tabs, this.controller}); @override Widget build(BuildContext context) { @@ -21,6 +22,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { bottom: 8, ), child: ButtonsTabBar( + controller: controller, radius: 100, decoration: BoxDecoration( color: bgColor, @@ -32,7 +34,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { ), borderWidth: 0, unselectedDecoration: BoxDecoration( - color: theme.colorScheme.background, + color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(15), ), unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith( diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index a9ec36b9..4b383c47 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -8,7 +8,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; @@ -23,6 +22,7 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; @@ -197,6 +197,8 @@ class TrackOptions extends HookConsumerWidget { return downloadManager.getProgressNotifier(spotubeTrack); }); + final isLocalTrack = track is LocalTrack; + final adaptivePopSheetList = AdaptivePopSheetList( onSelected: (value) async { switch (value) { @@ -314,118 +316,120 @@ class TrackOptions extends HookConsumerWidget { ), ), ], - children: switch (track.runtimeType) { - LocalTrack() => [ - PopSheetEntry( - value: TrackOptionValue.delete, - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), - ) - ], - _ => [ - 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, - leading: const Icon(SpotubeIcons.queueAdd), - title: Text(context.l10n.add_to_queue), - ), - PopSheetEntry( - value: TrackOptionValue.playNext, - leading: const Icon(SpotubeIcons.lightning), - title: Text(context.l10n.play_next), - ), - ] else - PopSheetEntry( - value: TrackOptionValue.removeFromQueue, - enabled: playlist.activeTrack?.id != track.id, - leading: const Icon(SpotubeIcons.queueRemove), - title: Text(context.l10n.remove_from_queue), - ), - if (me.asData?.value != null) - PopSheetEntry( - value: TrackOptionValue.favorite, - leading: favorites.isLiked - ? const Icon( - SpotubeIcons.heartFilled, - color: Colors.pink, - ) - : const Icon(SpotubeIcons.heart), - title: Text( - favorites.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, - ), - ), - if (auth != null) ...[ - PopSheetEntry( - value: TrackOptionValue.startRadio, - leading: const Icon(SpotubeIcons.radio), - title: Text(context.l10n.start_a_radio), - ), - PopSheetEntry( - value: TrackOptionValue.addToPlaylist, - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - ), - ], - if (userPlaylist && auth != null) - PopSheetEntry( - value: TrackOptionValue.removeFromPlaylist, - leading: const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), - ), - PopSheetEntry( - value: TrackOptionValue.download, - enabled: !isInQueue, - leading: isInQueue - ? HookBuilder(builder: (context) { - final progress = useListenable(progressNotifier!); - return CircularProgressIndicator( - value: progress.value, - ); - }) - : const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_track), + children: [ + if (isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.delete, + leading: const Icon(SpotubeIcons.trash), + title: Text(context.l10n.delete), + ), + 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, + leading: const Icon(SpotubeIcons.queueAdd), + title: Text(context.l10n.add_to_queue), + ), + PopSheetEntry( + value: TrackOptionValue.playNext, + leading: const Icon(SpotubeIcons.lightning), + title: Text(context.l10n.play_next), + ), + ] else + PopSheetEntry( + value: TrackOptionValue.removeFromQueue, + enabled: playlist.activeTrack?.id != track.id, + leading: const Icon(SpotubeIcons.queueRemove), + title: Text(context.l10n.remove_from_queue), + ), + if (me.asData?.value != null && !isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.favorite, + leading: favorites.isLiked + ? const Icon( + SpotubeIcons.heartFilled, + color: Colors.pink, + ) + : const Icon(SpotubeIcons.heart), + title: Text( + favorites.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, ), - PopSheetEntry( - value: TrackOptionValue.blacklist, - leading: const Icon(SpotubeIcons.playlistRemove), - iconColor: !isBlackListed ? Colors.red[400] : null, - textColor: !isBlackListed ? Colors.red[400] : null, - title: Text( - isBlackListed - ? context.l10n.remove_from_blacklist - : context.l10n.add_to_blacklist, - ), + ), + if (auth != null && !isLocalTrack) ...[ + PopSheetEntry( + value: TrackOptionValue.startRadio, + leading: const Icon(SpotubeIcons.radio), + title: Text(context.l10n.start_a_radio), + ), + PopSheetEntry( + value: TrackOptionValue.addToPlaylist, + leading: const Icon(SpotubeIcons.playlistAdd), + title: Text(context.l10n.add_to_playlist), + ), + ], + if (userPlaylist && auth != null && !isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.removeFromPlaylist, + leading: const Icon(SpotubeIcons.removeFilled), + title: Text(context.l10n.remove_from_playlist), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.download, + enabled: !isInQueue, + leading: isInQueue + ? HookBuilder(builder: (context) { + final progress = useListenable(progressNotifier!); + return CircularProgressIndicator( + value: progress.value, + ); + }) + : const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_track), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.blacklist, + leading: const Icon(SpotubeIcons.playlistRemove), + iconColor: !isBlackListed ? Colors.red[400] : null, + textColor: !isBlackListed ? Colors.red[400] : null, + title: Text( + isBlackListed + ? context.l10n.remove_from_blacklist + : context.l10n.add_to_blacklist, ), - PopSheetEntry( - value: TrackOptionValue.share, - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.share, + leading: const Icon(SpotubeIcons.share), + title: Text(context.l10n.share), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.songlink, + leading: Assets.logos.songlinkTransparent.image( + width: 22, + height: 22, + color: colorScheme.onSurface.withOpacity(0.5), ), - PopSheetEntry( - value: TrackOptionValue.songlink, - leading: Assets.logos.songlinkTransparent.image( - width: 22, - height: 22, - color: colorScheme.onSurface.withOpacity(0.5), - ), - title: Text(context.l10n.song_link), - ), - PopSheetEntry( - value: TrackOptionValue.details, - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.details), - ), - ] - }, + title: Text(context.l10n.song_link), + ), + if (!isLocalTrack) + PopSheetEntry( + value: TrackOptionValue.details, + leading: const Icon(SpotubeIcons.info), + title: Text(context.l10n.details), + ), + ], ); //! This is the most ANTI pattern I've ever done, but it works diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index 30912da2..e3aea4de 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -195,19 +195,26 @@ class TrackTile extends HookConsumerWidget { children: [ Expanded( flex: 6, - child: LinkText( - track.name!, - "/track/${track.id}", - push: true, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + child: switch (track) { + LocalTrack() => Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _ => LinkText( + track.name!, + "/track/${track.id}", + push: true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + }, ), if (constrains.mdAndUp) ...[ const SizedBox(width: 8), Expanded( flex: 4, - child: switch (track.runtimeType) { + child: switch (track) { LocalTrack() => Text( track.album!.name!, maxLines: 1, diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index f576ba0a..c3605f33 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -17,6 +17,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @@ -28,6 +29,7 @@ class TrackViewBodySection extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final props = InheritedTrackView.of(context); final trackViewState = ref.watch(trackViewProvider(props.tracks)); @@ -146,11 +148,17 @@ class TrackViewBodySection extends HookConsumerWidget { } else { final tracks = await props.pagination.onFetchAll(); await remotePlayback.load( - WebSocketLoadEventData( - tracks: tracks, - collectionId: props.collectionId, - initialIndex: index, - ), + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: tracks, + collection: props.collection as AlbumSimple, + initialIndex: index, + ) + : WebSocketLoadEventData.playlist( + tracks: tracks, + collection: props.collection as PlaylistSimple, + initialIndex: index, + ), ); } } else { @@ -164,6 +172,13 @@ class TrackViewBodySection extends HookConsumerWidget { autoPlay: true, ); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier + .addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } } } }, diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart index ff92b663..c2adf38b 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; @@ -8,6 +9,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; @@ -23,6 +25,7 @@ class TrackViewBodyOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final audioSource = ref.watch(userPreferencesProvider.select((s) => s.audioSource)); @@ -72,6 +75,12 @@ class TrackViewBodyOptions extends HookConsumerWidget { { playlistNotifier.addTracksAtFirst(selectedTracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } trackViewState.deselectAll(); break; } @@ -79,6 +88,12 @@ class TrackViewBodyOptions extends HookConsumerWidget { { playlistNotifier.addTracks(selectedTracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } trackViewState.deselectAll(); break; } diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/shared/tracks_view/sections/header/flexible_header.dart index 4a704302..d6e71e8f 100644 --- a/lib/components/shared/tracks_view/sections/header/flexible_header.dart +++ b/lib/components/shared/tracks_view/sections/header/flexible_header.dart @@ -1,7 +1,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; @@ -12,6 +12,7 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:gap/gap.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; +import 'package:spotube/utils/platform.dart'; class TrackViewFlexHeader extends HookConsumerWidget { const TrackViewFlexHeader({super.key}); @@ -53,7 +54,7 @@ class TrackViewFlexHeader extends HookConsumerWidget { floating: false, pinned: true, expandedHeight: 450, - automaticallyImplyLeading: DesktopTools.platform.isMobile, + automaticallyImplyLeading: kIsMobile, backgroundColor: palette.color, title: isExpanded ? null : Text(props.title, style: headingStyle), flexibleSpace: FlexibleSpaceBar( diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart index f6880485..8c1c8e15 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/heart_button.dart'; @@ -9,6 +10,7 @@ import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; class TrackViewHeaderActions extends HookConsumerWidget { @@ -20,6 +22,7 @@ class TrackViewHeaderActions extends HookConsumerWidget { final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); @@ -61,6 +64,13 @@ class TrackViewHeaderActions extends HookConsumerWidget { final tracks = await props.pagination.onFetchAll(); await playlistNotifier.addTracks(tracks); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier + .addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier + .addPlaylists([props.collection as PlaylistSimple]); + } }, ), if (props.onHeart != null && auth != null) diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index 50eeb747..5cc442cf 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -5,12 +5,14 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -28,6 +30,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final props = InheritedTrackView.of(context); final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); @@ -44,28 +47,45 @@ class TrackViewHeaderButtons extends HookConsumerWidget { try { isLoading.value = true; - final allTracks = await props.pagination.onFetchAll(); - + final initialTracks = props.tracks; if (!context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { + final allTracks = await props.pagination.onFetchAll(); final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( - tracks: allTracks, - collectionId: props.collectionId, - initialIndex: Random().nextInt(allTracks.length)), + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: props.collection as AlbumSimple, + initialIndex: Random().nextInt(allTracks.length)) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: props.collection as PlaylistSimple, + initialIndex: Random().nextInt(allTracks.length), + ), ); await remotePlayback.setShuffle(true); } else { await playlistNotifier.load( - allTracks, + initialTracks, autoPlay: true, - initialIndex: Random().nextInt(allTracks.length), + initialIndex: Random().nextInt(initialTracks.length), ); await audioPlayer.setShuffle(true); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([props.collection as PlaylistSimple]); + } + + final allTracks = await props.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); } } finally { isLoading.value = false; @@ -76,22 +96,39 @@ class TrackViewHeaderButtons extends HookConsumerWidget { try { isLoading.value = true; - final allTracks = await props.pagination.onFetchAll(); + final initialTracks = props.tracks; if (!context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { + final allTracks = await props.pagination.onFetchAll(); final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData( - tracks: allTracks, - collectionId: props.collectionId, - ), + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: allTracks, + collection: props.collection as AlbumSimple, + ) + : WebSocketLoadEventData.playlist( + tracks: allTracks, + collection: props.collection as PlaylistSimple, + ), ); } else { - await playlistNotifier.load(allTracks, autoPlay: true); + await playlistNotifier.load(initialTracks, autoPlay: true); playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([props.collection as PlaylistSimple]); + } + + final allTracks = await props.pagination.onFetchAll(); + + await playlistNotifier.addTracks( + allTracks.sublist(initialTracks.length), + ); } } finally { isLoading.value = false; diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/shared/tracks_view/track_view.dart index eb8f6871..03d628a8 100644 --- a/lib/components/shared/tracks_view/track_view.dart +++ b/lib/components/shared/tracks_view/track_view.dart @@ -1,5 +1,5 @@ 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:sliver_tools/sliver_tools.dart'; @@ -8,6 +8,7 @@ import 'package:spotube/components/shared/page_window_title_bar.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'; +import 'package:spotube/utils/platform.dart'; class TrackView extends HookConsumerWidget { const TrackView({super.key}); @@ -18,7 +19,7 @@ class TrackView extends HookConsumerWidget { final controller = useScrollController(); return Scaffold( - appBar: DesktopTools.platform.isDesktop + appBar: kIsDesktop ? const PageWindowTitleBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart index a1a07f84..b0a00ae2 100644 --- a/lib/components/shared/tracks_view/track_view_props.dart +++ b/lib/components/shared/tracks_view/track_view_props.dart @@ -39,7 +39,7 @@ class PaginationProps { } class InheritedTrackView extends InheritedWidget { - final String collectionId; + final Object collection; final String title; final String? description; final String image; @@ -55,7 +55,7 @@ class InheritedTrackView extends InheritedWidget { const InheritedTrackView({ super.key, required super.child, - required this.collectionId, + required this.collection, required this.title, this.description, required this.image, @@ -65,7 +65,11 @@ class InheritedTrackView extends InheritedWidget { required this.shareUrl, this.isLiked = false, this.onHeart, - }); + }) : assert(collection is AlbumSimple || collection is PlaylistSimple); + + String get collectionId => collection is AlbumSimple + ? (collection as AlbumSimple).id! + : (collection as PlaylistSimple).id!; @override bool updateShouldNotify(InheritedTrackView oldWidget) { @@ -78,7 +82,7 @@ class InheritedTrackView extends InheritedWidget { oldWidget.onHeart != onHeart || oldWidget.shareUrl != shareUrl || oldWidget.routePath != routePath || - oldWidget.collectionId != collectionId || + oldWidget.collection != collection || oldWidget.child != child; } diff --git a/lib/components/shared/waypoint.dart b/lib/components/shared/waypoint.dart index 08e9088a..cf00e29b 100644 --- a/lib/components/shared/waypoint.dart +++ b/lib/components/shared/waypoint.dart @@ -20,8 +20,6 @@ class Waypoint extends HookWidget { @override Widget build(BuildContext context) { - final isMounted = useIsMounted(); - useEffect(() { if (isGrid) { return null; @@ -32,19 +30,19 @@ class Waypoint extends HookWidget { // scrollController fetches the next paginated data when the current // position of the user on the screen has surpassed - if (controller.position.pixels >= nextPageTrigger && isMounted()) { + if (controller.position.pixels >= nextPageTrigger && context.mounted) { await onTouchEdge?.call(); } } WidgetsBinding.instance.addPostFrameCallback((_) { - if (controller.hasClients && isMounted()) { + if (controller.hasClients && context.mounted) { listener(); controller.addListener(listener); } }); return () => controller.removeListener(listener); - }, [controller, onTouchEdge, isMounted]); + }, [controller, onTouchEdge]); if (isGrid) { return VisibilityDetector( diff --git a/lib/components/stats/common/album_item.dart b/lib/components/stats/common/album_item.dart new file mode 100644 index 00000000..00b1cbfe --- /dev/null +++ b/lib/components/stats/common/album_item.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/album/album_card.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsAlbumItem extends StatelessWidget { + final AlbumSimple album; + final Widget info; + const StatsAlbumItem({super.key, required this.album, required this.info}); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (album.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + width: 40, + height: 40, + ), + ), + title: Text(album.name!), + subtitle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("${album.albumType?.formatted} • "), + Flexible( + child: ArtistLink( + artists: album.artists ?? [], + mainAxisAlignment: WrapAlignment.start, + ), + ), + ], + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: {"id": album.id!}, + extra: album, + ); + }, + ); + } +} diff --git a/lib/components/stats/common/artist_item.dart b/lib/components/stats/common/artist_item.dart new file mode 100644 index 00000000..9282d4e1 --- /dev/null +++ b/lib/components/stats/common/artist_item.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/artist/artist.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsArtistItem extends StatelessWidget { + final Artist artist; + final Widget info; + const StatsArtistItem({ + super.key, + required this.artist, + required this.info, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(artist.name!), + horizontalTitleGap: 8, + leading: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (artist.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: {"id": artist.id!}, + ); + }, + ); + } +} diff --git a/lib/components/stats/common/playlist_item.dart b/lib/components/stats/common/playlist_item.dart new file mode 100644 index 00000000..b07311ab --- /dev/null +++ b/lib/components/stats/common/playlist_item.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsPlaylistItem extends StatelessWidget { + final PlaylistSimple playlist; + final Widget info; + const StatsPlaylistItem( + {super.key, required this.playlist, required this.info}); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (playlist.images).asUrlString( + placeholder: ImagePlaceholder.collection, + ), + width: 40, + height: 40, + ), + ), + title: Text(playlist.name!), + subtitle: Text( + playlist.description!.replaceAll(htmlTagRegexp, ''), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + PlaylistPage.name, + pathParameters: {"id": playlist.id!}, + extra: playlist, + ); + }, + ); + } +} diff --git a/lib/components/stats/common/track_item.dart b/lib/components/stats/common/track_item.dart new file mode 100644 index 00000000..6ba6b886 --- /dev/null +++ b/lib/components/stats/common/track_item.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsTrackItem extends StatelessWidget { + final Track track; + final Widget info; + const StatsTrackItem({ + super.key, + required this.track, + required this.info, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + horizontalTitleGap: 8, + leading: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: UniversalImage( + path: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + width: 40, + height: 40, + ), + ), + title: Text(track.name!), + subtitle: ArtistLink( + artists: track.artists!, + mainAxisAlignment: WrapAlignment.start, + ), + trailing: info, + onTap: () { + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ); + }, + ); + } +} diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart new file mode 100644 index 00000000..61f3bd6c --- /dev/null +++ b/lib/components/stats/summary/summary.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/stats/summary/summary_card.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/pages/stats/albums/albums.dart'; +import 'package:spotube/pages/stats/artists/artists.dart'; +import 'package:spotube/pages/stats/fees/fees.dart'; +import 'package:spotube/pages/stats/minutes/minutes.dart'; +import 'package:spotube/pages/stats/playlists/playlists.dart'; +import 'package:spotube/pages/stats/streams/streams.dart'; +import 'package:spotube/provider/history/summary.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class StatsPageSummarySection extends HookConsumerWidget { + const StatsPageSummarySection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final summary = ref.watch(playbackHistorySummaryProvider); + + return SliverPadding( + padding: const EdgeInsets.all(10), + sliver: SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constrains.isXs + ? 2 + : constrains.smAndDown + ? 3 + : constrains.mdAndDown + ? 4 + : constrains.lgAndDown + ? 5 + : 6, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: constrains.isXs ? 1.3 : 1.5, + ), + delegate: SliverChildListDelegate([ + SummaryCard( + title: summary.duration.inMinutes.toDouble(), + unit: "minutes", + description: 'Listened to music', + color: Colors.purple, + onTap: () { + ServiceUtils.pushNamed(context, StatsMinutesPage.name); + }, + ), + SummaryCard( + title: summary.tracks.toDouble(), + unit: "songs", + description: 'Streamed overall', + color: Colors.lightBlue, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamsPage.name); + }, + ), + SummaryCard.unformatted( + title: usdFormatter.format(summary.fees.toDouble()), + unit: "", + description: 'Owed to artists\nthis month', + color: Colors.green, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); + }, + ), + SummaryCard( + title: summary.artists.toDouble(), + unit: "artist's", + description: 'Music reached you', + color: Colors.yellow, + onTap: () { + ServiceUtils.pushNamed(context, StatsArtistsPage.name); + }, + ), + SummaryCard( + title: summary.albums.toDouble(), + unit: "full albums", + description: 'Got your love', + color: Colors.pink, + onTap: () { + ServiceUtils.pushNamed(context, StatsAlbumsPage.name); + }, + ), + SummaryCard( + title: summary.playlists.toDouble(), + unit: "playlists", + description: 'Were on repeat', + color: Colors.teal, + onTap: () { + ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); + }, + ), + ]), + ); + }), + ); + } +} diff --git a/lib/components/stats/summary/summary_card.dart b/lib/components/stats/summary/summary_card.dart new file mode 100644 index 00000000..243c50e8 --- /dev/null +++ b/lib/components/stats/summary/summary_card.dart @@ -0,0 +1,86 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:spotube/collections/formatters.dart'; + +class SummaryCard extends StatelessWidget { + final String title; + final String unit; + final String description; + final VoidCallback? onTap; + + final MaterialColor color; + + SummaryCard({ + super.key, + required double title, + required this.unit, + required this.description, + required this.color, + this.onTap, + }) : title = compactNumberFormatter.format(title); + + const SummaryCard.unformatted({ + super.key, + required this.title, + required this.unit, + required this.description, + required this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final ThemeData(:textTheme, :brightness) = Theme.of(context); + + final descriptionNewLines = description.split("").where((s) => s == "\n"); + + return Card( + color: brightness == Brightness.dark ? color.shade100 : color.shade50, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AutoSizeText.rich( + TextSpan( + children: [ + TextSpan( + text: title, + style: textTheme.headlineLarge?.copyWith( + color: color.shade900, + ), + ), + TextSpan( + text: " $unit", + style: textTheme.titleMedium?.copyWith( + color: color.shade900, + ), + ), + ], + ), + maxLines: 1, + ), + const Gap(5), + AutoSizeText( + description, + maxLines: description.contains("\n") + ? descriptionNewLines.length + 1 + : 1, + minFontSize: 9, + style: textTheme.labelMedium!.copyWith( + color: color.shade900, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/components/stats/top/albums.dart b/lib/components/stats/top/albums.dart new file mode 100644 index 00000000..51bcf5b0 --- /dev/null +++ b/lib/components/stats/top/albums.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/stats/common/album_item.dart'; +import 'package:spotube/provider/history/top.dart'; + +class TopAlbums extends HookConsumerWidget { + const TopAlbums({super.key}); + + @override + Widget build(BuildContext context, ref) { + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final albums = ref.watch(playbackHistoryTopProvider(historyDuration) + .select((value) => value.albums)); + + return SliverList.builder( + itemCount: albums.length, + itemBuilder: (context, index) { + final album = albums[index]; + return StatsAlbumItem( + album: album.album, + info: Text( + "${compactNumberFormatter.format(album.count)} plays", + ), + ); + }, + ); + } +} diff --git a/lib/components/stats/top/artists.dart b/lib/components/stats/top/artists.dart new file mode 100644 index 00000000..d6d0c98d --- /dev/null +++ b/lib/components/stats/top/artists.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/provider/history/top.dart'; + +class TopArtists extends HookConsumerWidget { + const TopArtists({super.key}); + + @override + Widget build(BuildContext context, ref) { + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final artists = ref.watch(playbackHistoryTopProvider(historyDuration) + .select((value) => value.artists)); + + return SliverList.builder( + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text("${compactNumberFormatter.format(artist.count)} plays"), + ); + }, + ); + } +} diff --git a/lib/components/stats/top/top.dart b/lib/components/stats/top/top.dart new file mode 100644 index 00000000..df1275e8 --- /dev/null +++ b/lib/components/stats/top/top.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/themed_button_tab_bar.dart'; +import 'package:spotube/components/stats/top/albums.dart'; +import 'package:spotube/components/stats/top/artists.dart'; +import 'package:spotube/components/stats/top/tracks.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsPageTopSection extends HookConsumerWidget { + const StatsPageTopSection({super.key}); + + @override + Widget build(BuildContext context, ref) { + final tabController = useTabController(initialLength: 3); + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final historyDurationNotifier = + ref.watch(playbackHistoryTopDurationProvider.notifier); + + return SliverMainAxisGroup( + slivers: [ + SliverAppBar( + floating: true, + flexibleSpace: ThemedButtonsTabBar( + controller: tabController, + tabs: const [ + Tab( + child: Padding( + padding: EdgeInsets.all(5), + child: Text("Top Tracks"), + ), + ), + Tab( + child: Padding( + padding: EdgeInsets.all(5), + child: Text("Top Artists"), + ), + ), + Tab( + child: Padding( + padding: EdgeInsets.all(5), + child: Text("Top Albums"), + ), + ), + ], + ), + ), + SliverToBoxAdapter( + child: Align( + alignment: Alignment.centerRight, + child: DropdownButton( + style: Theme.of(context).textTheme.bodySmall!, + isDense: true, + padding: const EdgeInsets.all(4), + borderRadius: BorderRadius.circular(4), + underline: const SizedBox(), + value: historyDuration, + onChanged: (value) { + if (value == null) return; + historyDurationNotifier.update((_) => value); + }, + icon: const Icon(Icons.arrow_drop_down), + items: const [ + DropdownMenuItem( + value: HistoryDuration.days7, + child: Text("This week"), + ), + DropdownMenuItem( + value: HistoryDuration.days30, + child: Text("This month"), + ), + DropdownMenuItem( + value: HistoryDuration.months6, + child: Text("Last 6 months"), + ), + DropdownMenuItem( + value: HistoryDuration.year, + child: Text("This year"), + ), + DropdownMenuItem( + value: HistoryDuration.years2, + child: Text("Last 2 years"), + ), + DropdownMenuItem( + value: HistoryDuration.allTime, + child: Text("All time"), + ), + ], + ), + ), + ), + ListenableBuilder( + listenable: tabController, + builder: (context, _) { + return switch (tabController.index) { + 1 => const TopArtists(), + 2 => const TopAlbums(), + _ => const TopTracks(), + }; + }, + ), + ], + ); + } +} diff --git a/lib/components/stats/top/tracks.dart b/lib/components/stats/top/tracks.dart new file mode 100644 index 00000000..bffa4ecd --- /dev/null +++ b/lib/components/stats/top/tracks.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/provider/history/top.dart'; + +class TopTracks extends HookConsumerWidget { + const TopTracks({super.key}); + + @override + Widget build(BuildContext context, ref) { + final historyDuration = ref.watch(playbackHistoryTopDurationProvider); + final tracks = ref.watch( + playbackHistoryTopProvider(historyDuration) + .select((value) => value.tracks), + ); + + return SliverList.builder( + itemCount: tracks.length, + itemBuilder: (context, index) { + final track = tracks[index]; + return StatsTrackItem( + track: track.track, + info: Text( + "${compactNumberFormatter.format(track.count)} plays", + ), + ); + }, + ); + } +} diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart index 7c8ae09e..5678390c 100644 --- a/lib/extensions/album_simple.dart +++ b/lib/extensions/album_simple.dart @@ -1,21 +1,6 @@ import 'package:spotify/spotify.dart'; extension AlbumExtensions on AlbumSimple { - Map toJson() { - return { - "albumType": albumType?.name, - "id": id, - "name": name, - "images": images - ?.map((image) => { - "height": image.height, - "url": image.url, - "width": image.width, - }) - .toList(), - }; - } - Album toAlbum() { Album album = Album(); album.albumType = albumType; diff --git a/lib/extensions/artist_simple.dart b/lib/extensions/artist_simple.dart index 6a80300e..7997355d 100644 --- a/lib/extensions/artist_simple.dart +++ b/lib/extensions/artist_simple.dart @@ -1,17 +1,5 @@ import 'package:spotify/spotify.dart'; -extension ArtistJson on ArtistSimple { - Map toJson() { - return { - "href": href, - "id": id, - "name": name, - "type": type, - "uri": uri, - }; - } -} - extension ArtistExtension on List { String asString() { return map((e) => e.name?.replaceAll(",", " ")).join(", "); diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 9755179d..02c0c492 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -3,8 +3,6 @@ import 'dart:io'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/album_simple.dart'; -import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; extension TrackExtensions on Track { @@ -39,33 +37,6 @@ extension TrackExtensions on Track { return this; } - - Map toJson() { - return TrackExtensions.trackToJson(this); - } - - static Map trackToJson(Track track) { - return { - "album": track.album?.toJson(), - "artists": track.artists?.map((artist) => artist.toJson()).toList(), - "available_markets": track.availableMarkets?.map((e) => e.name).toList(), - "disc_number": track.discNumber, - "duration_ms": track.durationMs, - "explicit": track.explicit, - // "external_ids"track.: externalIds, - // "external_urls"track.: externalUrls, - "href": track.href, - "id": track.id, - "is_playable": track.isPlayable, - // "linked_from"track.: linkedFrom, - "name": track.name, - "popularity": track.popularity, - "preview_rrl": track.previewUrl, - "track_number": track.trackNumber, - "type": track.type, - "uri": track.uri, - }; - } } extension TrackSimpleExtensions on TrackSimple { diff --git a/lib/hooks/configurators/use_close_behavior.dart b/lib/hooks/configurators/use_close_behavior.dart index 79b14fa9..3df6a528 100644 --- a/lib/hooks/configurators/use_close_behavior.dart +++ b/lib/hooks/configurators/use_close_behavior.dart @@ -1,29 +1,31 @@ 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'; -// ignore: depend_on_referenced_packages import 'package:local_notifier/local_notifier.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.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); - }; +final closeNotification = !kIsDesktop + ? null + : (LocalNotification( + title: 'Spotube', + body: '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(); + await windowManager.hide(); closeNotification?.show(); } else { exit(0); diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart index 2650b05c..90d062dc 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -7,7 +7,7 @@ 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'; +import 'package:spotube/utils/platform.dart'; final appLinks = AppLinks(); final linkStream = appLinks.allStringLinkStream.asBroadcastStream(); @@ -53,7 +53,7 @@ void useDeepLinking(WidgetRef ref) { StreamSubscription? mediaStream; - if (DesktopTools.platform.isMobile) { + if (kIsMobile) { FlutterSharingIntent.instance.getInitialSharing().then(uriListener); mediaStream = diff --git a/lib/hooks/configurators/use_disable_battery_optimizations.dart b/lib/hooks/configurators/use_disable_battery_optimizations.dart index a9afef45..4aa51b74 100644 --- a/lib/hooks/configurators/use_disable_battery_optimizations.dart +++ b/lib/hooks/configurators/use_disable_battery_optimizations.dart @@ -1,12 +1,12 @@ import 'package:disable_battery_optimization/disable_battery_optimization.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + import 'package:spotube/hooks/utils/use_async_effect.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/utils/platform.dart'; void useDisableBatteryOptimizations() { useAsyncEffect(() async { - if (!DesktopTools.platform.isAndroid || - KVStoreService.askedForBatteryOptimization) return; + if (!kIsAndroid || KVStoreService.askedForBatteryOptimization) return; await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart index 86b495c4..9cccbfe0 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -1,17 +1,18 @@ import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; +import 'package:spotube/utils/platform.dart'; void useGetStoragePermissions(WidgetRef ref) { - final isMounted = useIsMounted(); + final context = useContext(); useAsyncEffect( () async { - if (!DesktopTools.platform.isMobile) return; + if (!kIsMobile) return; final androidInfo = await DeviceInfoPlugin().androidInfo; @@ -25,11 +26,11 @@ void useGetStoragePermissions(WidgetRef ref) { if (hasNoStoragePerm) { await Permission.storage.request(); - if (isMounted()) ref.invalidate(localTracksProvider); + if (context.mounted) ref.invalidate(localTracksProvider); } if (hasNoAudioPerm) { await Permission.audio.request(); - if (isMounted()) ref.invalidate(localTracksProvider); + if (context.mounted) ref.invalidate(localTracksProvider); } }, null, diff --git a/lib/hooks/configurators/use_init_sys_tray.dart b/lib/hooks/configurators/use_init_sys_tray.dart deleted file mode 100644 index 0bce6727..00000000 --- a/lib/hooks/configurators/use_init_sys_tray.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'dart:io'; - -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/collections/intents.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -void useInitSysTray(WidgetRef ref) { - final context = useContext(); - final systemTray = useRef(null); - - final initializeMenu = useCallback(() async { - systemTray.value?.destroy(); - final playlist = ref.read(proxyPlaylistProvider); - final playlistQueue = ref.read(proxyPlaylistProvider.notifier); - final preferences = ref.read(userPreferencesProvider); - if (!preferences.showSystemTrayIcon) { - await systemTray.value?.destroy(); - systemTray.value = null; - return; - } - final enabled = !playlist.isFetching; - systemTray.value = await DesktopTools.createSystemTrayMenu( - title: DesktopTools.platform.isWindows ? "Spotube" : "", - iconPath: "assets/spotube-logo.png", - windowsIconPath: "assets/spotube-logo.ico", - items: [ - MenuItemLabel( - label: "Show/Hide", - name: "show-hide", - onClicked: (item) async { - if (await DesktopTools.window.isVisible()) { - await DesktopTools.window.hide(); - } else { - await DesktopTools.window.show(); - } - }, - ), - MenuSeparator(), - MenuItemLabel( - label: "Play/Pause", - name: "play-pause", - enabled: enabled, - onClicked: (_) async { - Actions.maybeInvoke( - context, PlayPauseIntent(ref)) ?? - PlayPauseAction().invoke(PlayPauseIntent(ref)); - }, - ), - MenuItemLabel( - label: "Next", - name: "next", - enabled: enabled && (playlist.tracks.length) > 1, - onClicked: (p0) async { - await playlistQueue.next(); - }, - ), - MenuItemLabel( - label: "Previous", - name: "previous", - enabled: enabled && (playlist.tracks.length) > 1, - onClicked: (p0) async { - await playlistQueue.previous(); - }, - ), - MenuSeparator(), - MenuItemLabel( - label: "Quit", - name: "quit", - onClicked: (item) async { - exit(0); - }, - ), - ], - onEvent: (event, tray) async { - if (DesktopTools.platform.isWindows) { - switch (event) { - case SystemTrayEvent.click: - await DesktopTools.window.show(); - break; - case SystemTrayEvent.rightClick: - await tray.popUpContextMenu(); - break; - default: - } - } else { - switch (event) { - case SystemTrayEvent.rightClick: - await DesktopTools.window.show(); - break; - case SystemTrayEvent.click: - await tray.popUpContextMenu(); - break; - default: - } - } - }, - ); - }, [ref]); - - useReassemble(initializeMenu); - - ref.listen( - proxyPlaylistProvider, - (previous, next) { - initializeMenu(); - }, - ); - ref.listen( - userPreferencesProvider.select((s) => s.showSystemTrayIcon), - (previous, next) { - initializeMenu(); - }, - ); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - initializeMenu(); - }); - return () async { - await systemTray.value?.destroy(); - }; - }, [initializeMenu]); -} diff --git a/lib/hooks/configurators/use_update_checker.dart b/lib/hooks/configurators/use_update_checker.dart deleted file mode 100644 index 1a6a5be5..00000000 --- a/lib/hooks/configurators/use_update_checker.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:http/http.dart' as http; -import 'package:spotube/collections/env.dart'; - -import 'package:spotube/components/shared/links/anchor_button.dart'; -import 'package:spotube/hooks/controllers/use_package_info.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:version/version.dart'; - -void useUpdateChecker(WidgetRef ref) { - final isCheckUpdateEnabled = - ref.watch(userPreferencesProvider.select((s) => s.checkUpdate)); - final packageInfo = usePackageInfo( - appName: 'Spotube', - packageName: 'spotube', - ); - final Future> Function() checkUpdate = useCallback( - () async { - final value = await http.get( - Uri.parse( - "https://api.github.com/repos/KRTirtho/spotube/releases/latest"), - ); - final tagName = - (jsonDecode(value.body)["tag_name"] as String).replaceAll("v", ""); - final currentVersion = packageInfo.version == "Unknown" - ? null - : Version.parse(packageInfo.version); - final latestVersion = - tagName == "nightly" ? null : Version.parse(tagName); - return [currentVersion, latestVersion]; - }, - [packageInfo.version], - ); - - final context = useContext(); - - download(String url) => launchUrlString( - url, - mode: LaunchMode.externalApplication, - ); - - useEffect(() { - if (!Env.enableUpdateChecker) return; - if (!isCheckUpdateEnabled) return null; - checkUpdate().then((value) { - final currentVersion = value.first; - final latestVersion = value.last; - if (currentVersion == null || - latestVersion == null || - (latestVersion.isPreRelease && !currentVersion.isPreRelease) || - (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return; - if (latestVersion <= currentVersion) return; - showDialog( - context: context, - barrierDismissible: true, - barrierColor: Colors.black26, - builder: (context) { - const url = - "https://spotube.krtirtho.dev/other-downloads/stable-downloads"; - return AlertDialog( - title: const Text("Spotube has an update"), - actions: [ - FilledButton( - child: const Text("Download Now"), - onPressed: () => download(url), - ), - ], - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text("Spotube v${value.last} has been released"), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("Read the latest "), - AnchorButton( - "release notes", - style: const TextStyle(color: Colors.blue), - onTap: () => launchUrlString( - url, - mode: LaunchMode.externalApplication, - ), - ), - ], - ), - ], - ), - ); - }, - ); - }); - return null; - }, [packageInfo, isCheckUpdateEnabled]); -} diff --git a/lib/hooks/configurators/use_window_listener.dart b/lib/hooks/configurators/use_window_listener.dart index b91ad413..5977ea8e 100644 --- a/lib/hooks/configurators/use_window_listener.dart +++ b/lib/hooks/configurators/use_window_listener.dart @@ -1,6 +1,8 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; + import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.dart'; class CallbackWindowListener implements WindowListener { final VoidCallback? _onWindowClose; @@ -154,6 +156,8 @@ void useWindowListener({ VoidCallback? onWindowEvent, }) { useEffect(() { + if (!kIsDesktop) return null; + final listener = CallbackWindowListener( onWindowClose: onWindowClose, onWindowFocus: onWindowFocus, @@ -172,9 +176,9 @@ void useWindowListener({ onWindowUndocked: onWindowUndocked, onWindowEvent: onWindowEvent, ); - DesktopTools.window.addListener(listener); + windowManager.addListener(listener); return () { - DesktopTools.window.removeListener(listener); + windowManager.removeListener(listener); }; }, [ onWindowClose, diff --git a/lib/hooks/utils/use_palette_color.dart b/lib/hooks/utils/use_palette_color.dart index 9269edd7..e6d8b398 100644 --- a/lib/hooks/utils/use_palette_color.dart +++ b/lib/hooks/utils/use_palette_color.dart @@ -14,7 +14,6 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { final context = useContext(); final theme = Theme.of(context); final paletteColor = ref.watch(_paletteColorState); - final mounted = useIsMounted(); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { @@ -25,7 +24,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { width: 50, ), ); - if (!mounted()) return; + if (!context.mounted) return; final color = theme.brightness == Brightness.light ? palette.lightMutedColor ?? palette.lightVibrantColor : palette.darkMutedColor ?? palette.darkVibrantColor; @@ -41,7 +40,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { PaletteGenerator usePaletteGenerator(String imageUrl) { final palette = useState(PaletteGenerator.fromColors([])); - final mounted = useIsMounted(); + final context = useContext(); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { @@ -52,7 +51,7 @@ PaletteGenerator usePaletteGenerator(String imageUrl) { width: 50, ), ); - if (!mounted()) return; + if (!context.mounted) return; palette.value = newPalette; }); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 832862c0..04fc8566 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -107,6 +107,9 @@ "always_on_top": "Always on top", "exit_mini_player": "Exit Mini player", "download_location": "Download location", + "local_library": "Local library", + "add_library_location": "Add to library", + "remove_library_location": "Remove from library", "account": "Account", "login_with_spotify": "Login with your Spotify account", "connect_with_spotify": "Connect with Spotify", @@ -295,6 +298,7 @@ "delete_playlist": "Delete Playlist", "delete_playlist_confirmation": "Are you sure you want to delete this playlist?", "local_tracks": "Local Tracks", + "local_tab": "Local", "song_link": "Song Link", "skip_this_nonsense": "Skip this nonsense", "freedom_of_music": "“Freedom of Music”", @@ -320,5 +324,6 @@ "select": "Select", "connect_client_alert": "You're being controlled by {client}", "this_device": "This Device", - "remote": "Remote" + "remote": "Remote", + "stats": "Stats" } \ No newline at end of file diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb new file mode 100644 index 00000000..9a4ebb46 --- /dev/null +++ b/lib/l10n/app_eu.arb @@ -0,0 +1,324 @@ +{ + "guest": "Gonbidatua", + "browse": "Arakatu", + "search": "Bilatu", + "library": "Liburutegia", + "lyrics": "Hitzak", + "settings": "Ezarpenak", + "genre_categories_filter": "Kategoria edo generoak filtratu...", + "genre": "Generoa", + "personalized": "Pertsonalizatua", + "featured": "Nabarmenduak", + "new_releases": "Argitaratze berriak", + "songs": "Abestiak", + "playing_track": "{track} erreproduzitzen", + "queue_clear_alert": "Uneko zerrenda ezabatuko da. {track_length} abesti ezabatuko dira.\nJarraitu nahi duzu?", + "load_more": "Gehiago kargatu", + "playlists": "Zerrendak", + "artists": "Artistak", + "albums": "Albumak", + "tracks": "Kantak", + "downloads": "Deskargak", + "filter_playlists": "Zure zerrendak filtratu...", + "liked_tracks": "Gustuko Kantak", + "liked_tracks_description": "Zure gustuko kanta guztiak", + "create_playlist": "Sortu zerrenda", + "create_a_playlist": "Sortu zerrenda bat", + "update_playlist": "Eguneratu zerrenda", + "create": "Sortu", + "cancel": "Ezeztatu", + "update": "Eguneratu", + "playlist_name": "Zerrenda Izena", + "name_of_playlist": "Zerrendaren izena", + "description": "Deskribapena", + "public": "Publikoa", + "collaborative": "Kolaboratiboa", + "search_local_tracks": "Bilatu kanta lokalak...", + "play": "Erreproduzitu", + "delete": "Ezabatu", + "none": "Batere ez", + "sort_a_z": "Ordenatu A-Z", + "sort_z_a": "Ordenatu Z-A", + "sort_artist": "Ordenatu Artistaren arabera", + "sort_album": "Ordenatu Albumaren arabera", + "sort_duration": "Ordenar Iraupenaren arabera", + "sort_tracks": "Ordenatu Kantak", + "currently_downloading": "Oraintxe ({tracks_length}) deskargatzen", + "cancel_all": "Ezeztatu dena", + "filter_artist": "Filtratu artistak...", + "followers": "{followers} Jarraitzaile", + "add_artist_to_blacklist": "Gehitu artista zerrenda beltzera", + "top_tracks": "Top Kantak", + "fans_also_like": "Fan-ek hau ere gustuko dute", + "loading": "Kargatzen...", + "artist": "Artista", + "blacklisted": "Zerrenda beltzean", + "following": "Jarraitzen", + "follow": "Jarraitu", + "artist_url_copied": "Artistaren URL-a arbelera kopiatua", + "added_to_queue": "{tracks} kanta zerrendara gehituak", + "filter_albums": "Albumak filtratu...", + "synced": "Sinkronizatuta", + "plain": "Arrunta", + "shuffle": "Ausaz", + "search_tracks": "Bilatu kantak...", + "released": "Argitaratua", + "error": "Errorea: {error}", + "title": "Izenburua", + "time": "Iraupena", + "more_actions": "Ekintza gehiago", + "download_count": "({count}) deskarga", + "add_count_to_playlist": "Gehitu ({count}) zerrendara", + "add_count_to_queue": "Gehitu ({count}) ilarara", + "play_count_next": "Erreproduzitu hurrengo ({count})-ak", + "album": "Albuma", + "copied_to_clipboard": "{data} arbelean kopiatua", + "add_to_following_playlists": "Gehitu {track} hurrengo erreprodukzio-zerrendetara", + "add": "Gehitu", + "added_track_to_queue": "{track} zerrendan gehitua", + "add_to_queue": "Gehitu zerrendan", + "track_will_play_next": "{track} erreproduzituko da ondoren", + "play_next": "Hurrengo erreprodukzioa", + "removed_track_from_queue": "{track} zerrendatik ezabatua", + "remove_from_queue": "Ezabatu ilaratik", + "remove_from_favorites": "Ezabatu gogokoetatik", + "save_as_favorite": "Gorde gogokoetan", + "add_to_playlist": "Gehitu zerrendara", + "remove_from_playlist": "Ezabatu zerrendatik", + "add_to_blacklist": "Gehitu zerrenda beltzera", + "remove_from_blacklist": "Ezabatu zerrenda beltzetik", + "share": "Elkarbanatu", + "mini_player": "Mini Erreproduzitzailea", + "slide_to_seek": "Arrastatu aurrerantz edo atzearantz bilatzeko", + "shuffle_playlist": "Erreproduzitu zerrenda ausazko ordenean", + "unshuffle_playlist": "Desgaitu ausazko erreprodukzioa", + "previous_track": "Aurreko pista", + "next_track": "Hurrengo pista", + "pause_playback": "Pausatu erreprodukzioa", + "resume_playback": "Berrabiarazi erreprodukzioa", + "loop_track": "Kanta begiztan", + "repeat_playlist": "Errepikatu lista", + "queue": "Ilara", + "alternative_track_sources": "Kanten iturri alternatiboak", + "download_track": "Deskargatu kanta", + "tracks_in_queue": "{tracks} kanta zerrendan", + "clear_all": "Garbitu dena", + "show_hide_ui_on_hover": "Erakutsi/Ezkutatu interfazea kurtsorea pasatzean", + "always_on_top": "Beti ikusgai", + "exit_mini_player": "Irten mini erreproduzitzailetik", + "download_location": "Deskargen kokapena", + "account": "Kontua", + "login_with_spotify": "Hasi saioa zure Spotify kontuarekin", + "connect_with_spotify": "Spotify-rekin konektatu", + "logout": "Itxi saioa", + "logout_of_this_account": "Itxi kontu honen saioa", + "language_region": "Hizkuntza eta Herrialdea", + "language": "Hizkuntza", + "system_default": "Sisteman lehenetsia", + "market_place_region": "Dendaren herrialdea", + "recommendation_country": "Gomendio herrialdea", + "appearance": "Itxura", + "layout_mode": "Diseinu modua", + "override_layout_settings": "Responsive diseinu moduaren ezarpenak ezeztatu", + "adaptive": "Moldagarria", + "compact": "Trinkoa", + "extended": "Hedatua", + "theme": "Gaia", + "dark": "Iluna", + "light": "Argia", + "system": "Sistema", + "accent_color": "Azentu kolorea", + "sync_album_color": "Sinkronizatu albumaren kolorea", + "sync_album_color_description": "Albumaren artearen kolore nagusia erabili azentu kolore bezala", + "playback": "Erreprodukzioa", + "audio_quality": "Audioaren kalitatea", + "high": "Altua", + "low": "Baxua", + "pre_download_play": "Aurre-deskargatu eta erreproduzitu", + "pre_download_play_description": "Streaming egin beharrean, byte-ak deskargatu eta erreproduzitu (banda-zabalera handia duten erabiltzaileentzat gomendagarria)", + "skip_non_music": "Musika ez diren segmentuak baztertu (SponsorBlock)", + "blacklist_description": "Zerrenda beltzeko abesti eta artistak", + "wait_for_download_to_finish": "Mesedez, itxaron uneko deskarga bukatu arte", + "desktop": "Mahaigaina", + "close_behavior": "Ixterako Portaera", + "close": "Itxi", + "minimize_to_tray": "Sistemako erretilura minimizatu", + "show_tray_icon": "Erakutsi ikonoa sistemaren erretiluan", + "about": "Honi buruz", + "u_love_spotube": "Badakigu Spotube maite duzula", + "check_for_updates": "Bilatu eguneraketak", + "about_spotube": "Spotube-ri buruz", + "blacklist": "Zerrenda beltza", + "please_sponsor": "Mesedez, babestu/diruz lagundu", + "spotube_description": "Spotube, arina, plataforma-anitza eta doakoa den Spotify-ren bezeroa", + "version": "Bertsioa", + "build_number": "Konpilazio zenbakia", + "founder": "Sortzailea", + "repository": "Errepositorioa", + "bug_issues": "Erroreak eta arazoak", + "made_with": "Bangladesh🇧🇩-en ❤️-z egina", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Lizentzia", + "add_spotify_credentials": "Gehitu zure Spotify kredentzialak hasi ahal izateko", + "credentials_will_not_be_shared_disclaimer": "Ez arduratu, zure kredentzialak ez ditugu bilduko edo inorekin elkarbanatuko", + "know_how_to_login": "Ez dakizu nola egin?", + "follow_step_by_step_guide": "Jarraitu pausoz-pausoko gida", + "spotify_cookie": "Spotify-ren {name} cookiea", + "cookie_name_cookie": "{name} cookiea", + "fill_in_all_fields": "Mesedez, osatu eremu guztiak", + "submit": "Bidali", + "exit": "Irten", + "previous": "Aurrekoa", + "next": "Hurrengoa", + "done": "Eginda", + "step_1": "1. pausua", + "first_go_to": "Hasteko, joan hona", + "login_if_not_logged_in": "eta hasi saioa/sortu kontua lehendik ez baduzu eginda", + "step_2": "2. pausua", + "step_2_steps": "1. Saioa hasita duzularik, sakatu F12 edo saguaren eskuineko botoia klikatu > Ikuskatu nabigatzaileko garapen tresnak irekitzeko.\n2. Joan \"Aplikazio\" (Chrome, Edge, Brave, etab.) edo \"Biltegiratzea\" (Firefox, Palemoon, etab.)\n3. Joan \"Cookieak\" atalera eta gero \"https://accounts.spotify.com\" azpiatalera", + "step_3": "3. pausua", + "step_3_steps": "Kopiatu \"sp_dc\" cookiearen balioa", + "success_emoji": "Eginda! 🥳", + "success_message": "Ongi hasi duzu zure Spotify kontua. Lan bikaina, lagun!", + "step_4": "4. pausua", + "step_4_steps": "Itsatsi \"sp_dc\"-tik kopiatutako balioa", + "something_went_wrong": "Zerbaitek huts egin du", + "piped_instance": "Piped zerbitzariaren instantzia", + "piped_description": "Kanten koizidentzietan erabiltzeko Piped zerbitzariaren instantzia", + "piped_warning": "Batzuk agian ez dute ongi funtzionatuko, zure ardurapean erabili", + "generate_playlist": "Sortu Zerrenda", + "track_exists": "{track} kanta dagoeneko badago", + "replace_downloaded_tracks": "Ordezkatu deskargatutako kanta guztiak", + "skip_download_tracks": "Deskargatutako kanta guztien deskarga baztertu", + "do_you_want_to_replace": "Dagoen kanta ordezkatu nahi duzu??", + "replace": "Ordezkatu", + "skip": "Baztertu", + "select_up_to_count_type": "Aukertu {count} {type}", + "select_genres": "Aukeratu Generoak", + "add_genres": "Gehitu Generoak", + "country": "Herrialdea", + "number_of_tracks_generate": "Sortzeko kanta kopurua", + "acousticness": "Akustikotasuna", + "danceability": "Dantzagarritasuna", + "energy": "Energia", + "instrumentalness": "Instrumentaltasuna", + "liveness": "Zuzenean", + "loudness": "Ozentasuna", + "speechiness": "Hitzaldia", + "valence": "Balentzia", + "popularity": "Populartasuna", + "key": "Tonua", + "duration": "Iraupena (s)", + "tempo": "Tenpoa (BPM)", + "mode": "Modua", + "time_signature": "Konpasa", + "short": "Motza", + "medium": "Ertaina", + "long": "Luzea", + "min": "Min.", + "max": "Max.", + "target": "Helburua", + "moderate": "Moderatua", + "deselect_all": "Desaukeratu dena", + "select_all": "Aukeratu dena", + "are_you_sure": "Ziur zaude?", + "generating_playlist": "Zure pertsonalizatutako zerrenda sortzen...", + "selected_count_tracks": "{count} kanta aukeratuta", + "download_warning": "Abesti guztiak aldi berean deskargatuz gero, argi dago musika pirateatzen ari zarela eta musikaren gizarte sortzaileari kalte egiten diozula. Honen jakitun izan eta artisten lan gogorra errespetatu eta babestea espero dut", + "download_ip_ban_warning": "Bidenabar, baliteke zure IPa YouTuben blokeatzea deskarga eskera gehiegi egiten badituzu. IPa blokeatzeak esan nahi du ezin izango duzula YouTube erabili (nahiz eta saioa hasia izan) gutxienez 2-3 hilabetez IP helbide horretatik. Eta Spotube ez da erantzule izango hori gertatzen bazaizu", + "by_clicking_accept_terms": "'Onartu' klikatzean, ondorengo baldintzak onartzen dituzu:", + "download_agreement_1": "Badakit musika pirateatzen ari naizela. Gaiztoa naiz", + "download_agreement_2": "Ahal dudanean lagunduko diot artistari baina oraingoz ez dut bere artea erosteko dirurik", + "download_agreement_3": "Erabat jakitun naiz YouTubek nire IPa blokea dezakeela eta ez diot Spotube-ri edo bere jabe/laguntzaileei erantzukizunik eskatuko nire oraingo jokaerak ekar ditzakeen arazoengatik", + "decline": "Baztertu", + "accept": "Onartu", + "details": "Xehetasunak", + "youtube": "YouTube", + "channel": "Kanala", + "likes": "Gustukoak", + "dislikes": "Ez gustukoak", + "views": "Ikuspenak", + "streamUrl": "Streaming-aren URLa", + "stop": "Gelditu", + "sort_newest": "Ordenatu gehitu berrienetik", + "sort_oldest": "Ordenatu gehitu zaharrenetik", + "sleep_timer": "Itzaltzeko tenporizadorea", + "mins": "{minutes} minutu", + "hours": "{hours} ordu", + "hour": "{hours} ordu", + "custom_hours": "Ordu pertsonalizatuak", + "logs": "Log-ak", + "developers": "Garatzaileak", + "not_logged_in": "Ez duzu saioa hasi", + "search_mode": "Bilaketa modua", + "audio_source": "Audio Iturria", + "ok": "OK", + "failed_to_encrypt": "Errorea zifratzean", + "encryption_failed_warning": "Spotube-ek zifratzea darabil datuak modu seguruan biltegiratzeko. Baina huts egin du. Hori dela eta, biltegiratzea ez da segurua izango\nLinux erabiltzen ari bazara, ziurtatu edozein sekretu-zerbitzu (gnome-keyring, kde-wallet, keepassxc etab.) instalatuta duzula", + "querying_info": "Informazioa egiaztatzen...", + "piped_api_down": "Piped-en APIa ez dago eskuragarri", + "piped_down_error_instructions": "Piped-en {pipedInstance} instantzia ez dago martxan une honetan\n\nAldatu instantzia edo aldatu 'API mota' YouTuberen API ofizialera\n\nZiurtatu aplikazioa berrabiarazten duzula aldaketa eta gero", + "you_are_offline": "Une honetan konexiorik gabe zaude", + "connection_restored": "Internet konexioa berrezarri egin da", + "use_system_title_bar": "Erabili sistemako izenburu barra", + "crunching_results": "Emaitzak prozesatzen...", + "search_to_get_results": "Bilatu emaitzak lortzeko", + "use_amoled_mode": "Erabili AMOLED modua", + "pitch_dark_theme": "Dart-en gai iluna", + "normalize_audio": "Normalizatu audioa", + "change_cover": "Aldatu azala", + "add_cover": "Gehitu azala", + "restore_defaults": "Berrezarri berezko balioak", + "download_music_codec": "Deskargatutako musikaren codec-a", + "streaming_music_codec": "Streaming musikaren codec-a", + "login_with_lastfm": "Hasi saioa Last.fm-n", + "connect": "Konektatu", + "disconnect_lastfm": "Deskonektatu Last.fm-tik", + "disconnect": "Deskonektatu", + "username": "Erabiltzaile izena", + "password": "Pasahitza", + "login": "Hasi saioa", + "login_with_your_lastfm": "Hasi saioa Last.fm-ko zure kontuarekin", + "scrobble_to_lastfm": "Scrobble Last.fm-ra", + "go_to_album": "Albumera joan", + "discord_rich_presence": "Discord-en presentzia aberatsa", + "browse_all": "Esploratu dena", + "genres": "Generoak", + "explore_genres": "Esploratu generoak", + "friends": "Lagunak", + "no_lyrics_available": "Sentitzen dut, ezin dira kanta honen hitzak aurkitu", + "start_a_radio": "Hasi Irrati bat", + "how_to_start_radio": "Nola hasi nahi duzu irratia?", + "replace_queue_question": "Uneko zerrenda ordezkatu nahi duzu edo bertan gehitu?", + "endless_playback": "Amaigabeko erreprodukzioa", + "delete_playlist": "Ezabatu zerrenda", + "delete_playlist_confirmation": "Ziur zaude zerrenda ezabatu nahi duzula?", + "local_tracks": "Kanta lokalak", + "song_link": "Kantaren lotura", + "skip_this_nonsense": "Utzi txorakeria hau", + "freedom_of_music": "“Musika Askatasuna”", + "freedom_of_music_palm": "“Musika Askatasuna zure eskuetan”", + "get_started": "Has gaitezen", + "youtube_source_description": "Gomendatua eta hobekien dabilena.", + "piped_source_description": "Aske zara? YouTube bezala, baino askeago.", + "jiosaavn_source_description": "Asia hegoaldeko herrialdeetarako hoberena.", + "highest_quality": "Kalitate Onena: {quality}", + "select_audio_source": "Aukeratu Audio Iturria", + "endless_playback_description": "Gehitu automatikoki kanta berriak\n ilararen bukaeran", + "choose_your_region": "Aukeratu zure herrialdea", + "choose_your_region_description": "Honekin Spotube-k zure kokalerakuari dagokion edukia\neskeiniko dizu.", + "choose_your_language": "Aukeratu zure hizkuntza", + "help_project_grow": "Lagundu proiektu honi hazten", + "help_project_grow_description": "Spotube kode irekiko proiektu bat da. Proiektu hau hazten lagundu dezakezu, erroreak jakinaraziz edo ezaugarri berriak proposatuz.", + "contribute_on_github": "GitHub-en lagundu", + "donate_on_open_collective": "Open Collective-en diruz lagundu", + "browse_anonymously": "Nabigatu Anonimoki", + "enable_connect": "Gaitu konexioa", + "enable_connect_description": "Kontrolatu Spotube beste gailu batzuetatik", + "devices": "Gailuak", + "select": "Aukeratu", + "connect_client_alert": "{client} gailuak kontrolatzen zaitu", + "this_device": "Gailu hau", + "remote": "Urrunekoa" +} \ No newline at end of file diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb new file mode 100644 index 00000000..35470791 --- /dev/null +++ b/lib/l10n/app_fi.arb @@ -0,0 +1,324 @@ +{ + "guest": "Vieras", + "browse": "Selaa", + "search": "Hae", + "library": "Kirjasto", + "lyrics": "Lyriikat", + "settings": "Asetukset", + "genre_categories_filter": "Suodata kategorioita tai genrejä", + "genre": "Genre", + "personalized": "Personoidut", + "featured": "Esittelyssä", + "new_releases": "Uusi julkaisu", + "songs": "Laulut", + "playing_track": "Soitetaan {track}", + "queue_clear_alert": "Tämä tulee tyhjentämään jonon. {track_length} Kappaleita poistetaan\nHaluatko jatkaa?", + "load_more": "Lataa lisää", + "playlists": "Soittolistat", + "artists": "Artistit", + "albums": "Albumit", + "tracks": "Kappaleet", + "downloads": "Lataukset", + "filter_playlists": "Suodata soittolistasi...", + "liked_tracks": "Tykätyt kappaleet", + "liked_tracks_description": "Kaikki tykättysi kappaleet", + "create_playlist": "Luo soittolista", + "create_a_playlist": "Luo soittolista", + "update_playlist": "Päivitä soittolista", + "create": "Luo", + "cancel": "Peruuta", + "update": "Päivitä", + "playlist_name": "Soittolistan nimi", + "name_of_playlist": "Soittolistan nimi", + "description": "Kuvaus", + "public": "Julkinen", + "collaborative": "Collaborative", + "search_local_tracks": "Hae paikallisia lauluja...", + "play": "Soita", + "delete": "Poista", + "none": "Ei mitään", + "sort_a_z": "Suodata A-Z", + "sort_z_a": "Suodata Z-A", + "sort_artist": "Suodata Artistilta", + "sort_album": "Suodata Albumilta", + "sort_duration": "Suodata Pituudelta", + "sort_tracks": "Suodata Kappaleet", + "currently_downloading": "Ladataan ({tracks_length})", + "cancel_all": "Peru kaikki", + "filter_artist": "Suodata artistit...", + "followers": "{followers} Seuraajaa", + "add_artist_to_blacklist": "Lisää artisti mustalle listalle", + "top_tracks": "Suosituimmat kappaleet", + "fans_also_like": "Fanit myös tykkäsivät", + "loading": "Ladataan...", + "artist": "Artisti", + "blacklisted": "Mustalistattu", + "following": "Seurataan", + "follow": "Seuraa", + "artist_url_copied": "Aristin URL kopioitiin leikepöytään", + "added_to_queue": "Lisättiin {tracks} kappaletta jonoon", + "filter_albums": "Suodata albumit...", + "synced": "Synkronoitu", + "plain": "Tavallinen", + "shuffle": "Sekoita", + "search_tracks": "Hae kappaleita...", + "released": "Julkaistu", + "error": "Virhe {error}", + "title": "Otsikko", + "time": "Aika", + "more_actions": "Lisää toimintoja", + "download_count": "Lataa ({count})", + "add_count_to_playlist": "Lisää ({count}) Soittolistaasi", + "add_count_to_queue": "Lisää ({count}) Jonoon", + "play_count_next": "Soita ({count}) seuraavaksi", + "album": "Albumi", + "copied_to_clipboard": "Kopioitiin {data} leikepöytään", + "add_to_following_playlists": "Lisää {track} seuraaviin soittolistoihin", + "add": "Lisää", + "added_track_to_queue": "Lisättiin {track} jonoon", + "add_to_queue": "Lisää jonoon", + "track_will_play_next": "{track} Soitetaan seuraavaksi", + "play_next": "Soita seuraavaksi", + "removed_track_from_queue": "Poistettiin {track} jonosta", + "remove_from_queue": "Poista jonosta", + "remove_from_favorites": "Poista suosikeista", + "save_as_favorite": "Tallenna soittolistana", + "add_to_playlist": "Lisää soittolistaan", + "remove_from_playlist": "Poista soittolistasta", + "add_to_blacklist": "Lisää mustalle listalle", + "remove_from_blacklist": "Poista mustalistalta", + "share": "Jaa", + "mini_player": "Minisoitin", + "slide_to_seek": "Liu'uta mennäkseen eteenpäin tai taaksepäin", + "shuffle_playlist": "Sekoita soittolista", + "unshuffle_playlist": "Poista sekoitus soittolistasta", + "previous_track": "Äskeinen kappale", + "next_track": "Seuraava kappale", + "pause_playback": "Pysäytä soittolistan toisto", + "resume_playback": "Jatka soittolistan toistoa", + "loop_track": "Uudelleentoista kappale", + "repeat_playlist": "Toista soittolista uudelleen", + "queue": "Jono", + "alternative_track_sources": "Toinen kappale lähde", + "download_track": "Lataa kappale", + "tracks_in_queue": "{tracks} kappaletta jonossa", + "clear_all": "Tyhjennä kaikki", + "show_hide_ui_on_hover": "Näytä/Piilota UI leijumalla", + "always_on_top": "Aina päällimmäisenä", + "exit_mini_player": "Lähde minisoittimesta", + "download_location": "Lataus sijainti", + "account": "Käyttäjä", + "login_with_spotify": "Kirjaudu Spotify-käyttäjällä", + "connect_with_spotify": "Yhdistä Spotify:lla", + "logout": "Kirjaudu ulos", + "logout_of_this_account": "Kirjaudu ulos tältä käyttäjältä", + "language_region": "Kieli ja Maa", + "language": "Kieli", + "system_default": "Järjestelmän oletus", + "market_place_region": "Markkina-alue", + "recommendation_country": "Suositeltu maa", + "appearance": "Ulkomuto", + "layout_mode": "Asettelutila", + "override_layout_settings": "Jätä reagoiva asettelutila huomioimatta", + "adaptive": "Mukautuva", + "compact": "Kompakti", + "extended": "Laajennettu", + "theme": "Teema", + "dark": "Tumma", + "light": "Vaalea", + "system": "Järjestelmä", + "accent_color": "Korostusväri", + "sync_album_color": "Synkronoi albumin väri", + "sync_album_color_description": "Käyttää albumin kansitaiteen vallitsevaa väirä korostuvärinä", + "playback": "Toisto", + "audio_quality": "Äänenlaatu", + "high": "Korkea", + "low": "Matala", + "pre_download_play": "Esilataa ja soita", + "pre_download_play_description": "Audion suoratoiston sijaan, lataa tavut ja soita ne (Suositeltu korkeamman kaistanleveyden käyttäjille)", + "skip_non_music": "Ohita ei-musiikki kohdat (SponsorBlock)", + "blacklist_description": "Mustalistat kappaleet aja artistit", + "wait_for_download_to_finish": "Odota nykyisen latauksen lopetteluun", + "desktop": "Työpöytä", + "close_behavior": "Sulkemisen käyttäytyminen", + "close": "Sulje", + "minimize_to_tray": "Minimisoi tehtäväpalkkiin", + "show_tray_icon": "Näytä järjestelmäkuvake", + "about": "Tietoa", + "u_love_spotube": "Tiedämme että rakastat Spotubea", + "check_for_updates": "Tarkista päivitykset", + "about_spotube": "Tietoa Spotube:sta", + "blacklist": "Mustalista", + "please_sponsor": "Sponsoroi/Lahjoita, kiitos", + "spotube_description": "Spotube, kevyt, cross-platform, vapaa-kaikille spotify clientti", + "version": "Versio", + "build_number": "Rakennusnumero", + "founder": "Perustaja", + "repository": "Arkisto", + "bug_issues": "Bugit+Ongelmat", + "made_with": "Tehty ❤️ Bangladeshista 🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Lisenssi", + "add_spotify_credentials": "Lisää Spotify-tunnuksesi aloittaaksesi", + "credentials_will_not_be_shared_disclaimer": "Älä huoli, tunnuksiasi ei talleteta tai jaeta kenenkään kanssa", + "know_how_to_login": "Etkö tiedä miten tehdä tämä?", + "follow_step_by_step_guide": "Seuraa askel askeleelta opasta", + "spotify_cookie": "Spotify {name} Keksi", + "cookie_name_cookie": "{name} Keksi", + "fill_in_all_fields": "Täytä kaikki kentät", + "submit": "Lähetä", + "exit": "Poistu", + "previous": "Edellinen", + "next": "Seuraava", + "done": "Tehty", + "step_1": "Vaihe 1", + "first_go_to": "Ensiksi, mene", + "login_if_not_logged_in": "ja Kirjaudu/Tee tili jos et ole kirjautunut sisään", + "step_2": "Vaihe 2", + "step_2_steps": "1. Kun olet kirjautunut, paina F12 tai oikeaa hiiren näppäintä > Tarkista ja avaa selaimen kehittäjä työkalut.\n2. Mene sitten \"Application\"-välilehteen (Chrome, Edge, Brave jne..) tai \"Storage\"-välilehteen (Firefox, Palemoon jne..)\n3. Mene \"Cookies\"-osastoon, sitten \"https://accounts.spotify.com\" alakohtaan.", + "step_3": "Vaihe 3", + "step_3_steps": "Kopioi Keksin \"sp_dc\" arvo", + "success_emoji": "Onnistuit🥳", + "success_message": "Olet nyt kirjautunut sisään Spotify-käyttäjällesi. Hyvää työtä toveri!", + "step_4": "Vaihe 4", + "step_4_steps": "Liitä kopioitu \"sp_dc\" arvo", + "something_went_wrong": "Jotain meni pieleen", + "piped_instance": "Johdettu palvelinesiintymä", + "piped_description": "Johdettu palvelinesiintymä Kappale täsmäyksiin", + "piped_warning": "Jotkut niistä eivät toimi hyvin, käytä siis omalla vastuullasi", + "generate_playlist": "Tuota soittolista", + "track_exists": "Kappale {track} on jo olemassa!", + "replace_downloaded_tracks": "Korvaa kaikki ladatut kappaleet", + "skip_download_tracks": "Ohita ladattujen laulujen lataaminen", + "do_you_want_to_replace": "Haluatko korvata olemassa olevan kappaleen??", + "replace": "Korvaa", + "skip": "Ohita", + "select_up_to_count_type": "Valitse enintään {count} {type}", + "select_genres": "Valitse Genret", + "add_genres": "Lisää Genrejä", + "country": "Maa", + "number_of_tracks_generate": "Numero tuotettavia kappaleita", + "acousticness": "Akustisuus", + "danceability": "Tanssittavuus", + "energy": "Energia", + "instrumentalness": "Instrumentaalisuus", + "liveness": "Elävyyttä", + "loudness": "Äänekkyys", + "speechiness": "Puheisuus", + "valence": "Valenssi", + "popularity": "Suosio", + "key": "Sävellaji", + "duration": "Pituus (s)", + "tempo": "Tempo (BPM)", + "mode": "Tila", + "time_signature": "Aikamerkki", + "short": "Lyhyt", + "medium": "Keskikokoinen", + "long": "Pitkä", + "min": "Minimi", + "max": "Maximi", + "target": "Kohde", + "moderate": "Kohtalainen", + "deselect_all": "Poista kaikki valinnat", + "select_all": "Valitse kaikki", + "are_you_sure": "Oletko varma?", + "generating_playlist": "Luodaan mukautettua soittolistoa...", + "selected_count_tracks": "Valittu {count} kappaletta", + "download_warning": "Jos lataat kaikki laulut kerrällä olet selkeästi Piratoimassa ja aiheuttamassa vahinkoa musiikin luovaan yhteiskuntaan. Toivottavasti olet tietoinen tästä. Yritä aina kunnioittaa ja tukea Artistin kovaa työtä.", + "download_ip_ban_warning": "BTW, YouTube voi estää IP-Osoitteesi tavallista liiallisten latauspyyntöjen takia. IP-Osoitteen esto tarkoittaa sitä, ettet voi käyttää YouTubea (vaikka olisit kirjautunut) vähintään 2-3kk aikana kyseiseltä laitteelta. Spotube ei kanna yhtään vastuuta jos se tapahtuu.", + "by_clicking_accept_terms": "Painamalla 'hyväksy' hyväksyt seuraaviin ehtoihin:", + "download_agreement_1": "Tiedän että Piratoin musiikkia. Olen paha.", + "download_agreement_2": "Tuen Artisteja silloin kun pystyn, ja teen tämän vain koska minulla ei ole rahaa ostaa heidän taidetta", + "download_agreement_3": "Ymmärrän että minun YouTube voi estää IP-Osoitteeni ja en pidä Spotubea tai omistajiinsa/avustajia vastuullisena mistään omista teoistsani", + "decline": "Hylkää", + "accept": "Hyväksy", + "details": "Yksityiskohdat", + "youtube": "YouTube", + "channel": "Kanava", + "likes": "Tykkäykset", + "dislikes": "Epä-tykkäykset", + "views": "Näyttökerrat", + "streamUrl": "Suoratoiston URL", + "stop": "Lopeta", + "sort_newest": "Suodata uusimmista", + "sort_oldest": "Suodata vanhimmista", + "sleep_timer": "Uniajastin", + "mins": "{minutes} Minuuttia", + "hours": "{hours} Tuntia", + "hour": "{hours} Tunti", + "custom_hours": "Mukautetut tunnit", + "logs": "Lokit", + "developers": "Kehittäjät", + "not_logged_in": "Et ole kirjautunut sisään.", + "search_mode": "Hakutila", + "audio_source": "Äänilähde", + "ok": "Ok", + "failed_to_encrypt": "Salaaminen epäonnistui", + "encryption_failed_warning": "Spotube käyttää salausta tallentaakseen tietosi, mutta epäonnistui, joten se palaa epäturvalliseen tallennukseen\nJos käytät Linuxia, varmista että sinulla on turvallisuuspalvelu (gnome-keyring, kde-wallet, keepassxc jne) asennettu", + "querying_info": "Hankitaan tietoa...", + "piped_api_down": "Johdettu palvelinesiintymä on alhaalla", + "piped_down_error_instructions": "Johdettu palvelinesiintymä {pipedInstance} on alhaalla.\n\nVaihda joko ilmeytymä tia vahda 'API tyyppi' YouTuben viralliseen API\n\nKäynnistä sovellus uudestaan vaihdon jälkeen", + "you_are_offline": "Et ole yhdistetty verkkoon", + "connection_restored": "Verkkoyhteys palautettu", + "use_system_title_bar": "Käytä järjestelmäpalkkia", + "crunching_results": "Paloitellaan tuloksia...", + "search_to_get_results": "Hae saadakseen tuloksia", + "use_amoled_mode": "Pilkkopimeä tumma teema", + "pitch_dark_theme": "AMOLED Tila", + "normalize_audio": "Normalisoi audio", + "change_cover": "Vaihda koveri", + "add_cover": "Lisää koveri", + "restore_defaults": "Palauta oletukset", + "download_music_codec": "Ladatun musiikin codefc", + "streaming_music_codec": "Suoratoistetun musiikin codec", + "login_with_lastfm": "Kirjaudu sisään Last.fm:llä", + "connect": "Yhdistä", + "disconnect_lastfm": "Katkaise Last.fm", + "disconnect": "Katkaise", + "username": "Käyttäjänimi", + "password": "Salasana", + "login": "Kirjaudu", + "login_with_your_lastfm": "Kirjaudu Last.fm käyttäjälläsi", + "scrobble_to_lastfm": "Scrobble Last.fm:ään", + "go_to_album": "Mene albumiin", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "Selaa kaikki", + "genres": "Genret", + "explore_genres": "Seikkaile genrejä", + "friends": "Kaverit", + "no_lyrics_available": "Anteeksi, emme löytäneet lyriikoita tälle laululle", + "start_a_radio": "Aloita Radio", + "how_to_start_radio": "Kuinka haluat aloittaa radion?", + "replace_queue_question": "Haluatko korvata nykyisen jonon vai lisätä siihen?", + "endless_playback": "Loputon toisto", + "delete_playlist": "Poista soittolista", + "delete_playlist_confirmation": "Oletko varma että haluat poistaa tämän soittolistan?", + "local_tracks": "Paikalliset kappaleet", + "song_link": "Laulun linkki", + "skip_this_nonsense": "Ohita tämä hölynpöly", + "freedom_of_music": "“Musiikin vapaus”", + "freedom_of_music_palm": "“Musiikin vapaus käsissäsi”", + "get_started": "Aloitetaan", + "youtube_source_description": "Suositeltu ja toimii parhaiten.", + "piped_source_description": "Tuntuuko vapaalta? Sama kuin YouTube mutta paljon vapautta", + "jiosaavn_source_description": "Paras Etelä-Aasian alueelle.", + "highest_quality": "Korkein laatu: {quality}", + "select_audio_source": "Valitse äänilähde", + "endless_playback_description": "Lisää automaattisesti uusia lauluja\njonon perään", + "choose_your_region": "Valitse alueesi", + "choose_your_region_description": "Tämä auttaa Spotube näyttämään sinulle oikeaa sisältöä\nsijaintiasi varten.", + "choose_your_language": "Valitse kielesi", + "help_project_grow": "Auta tätä projektia kasvamaan", + "help_project_grow_description": "Spotube projekti minkä lähdekoodi on julkisesti saatavilla. Voit autta tätä projektia kasvamaan muutoksilla, ilmoittamalla bugeista, tai ehdottamalla uusia ominaisuuksia.", + "contribute_on_github": "Auta GitHub:ssa", + "donate_on_open_collective": "Lahjoita avoimessa kollektiivissa", + "browse_anonymously": "Selaa anonyyminä", + "enable_connect": "Ota käyttöön yhdistäminen", + "enable_connect_description": "Ohjaa Spotubea toiselta laitteelta", + "devices": "Laitteet", + "select": "Valitse", + "connect_client_alert": "{client} ohjaa sinua", + "this_device": "Tämä laite", + "remote": "Etä" +} \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb new file mode 100644 index 00000000..b94cdd28 --- /dev/null +++ b/lib/l10n/app_id.arb @@ -0,0 +1,324 @@ +{ + "guest": "Tamu", + "browse": "Jelajahi", + "search": "Cari", + "library": "Pustaka", + "lyrics": "Lirik", + "settings": "Pengaturan", + "genre_categories_filter": "Urutkan kategori atau genre...", + "genre": "Genre", + "personalized": "Dipersonalisasi", + "featured": "Unggulan", + "new_releases": "Rilis Terbaru", + "songs": "Lagu", + "playing_track": "Memutar {track}", + "queue_clear_alert": "Ini akan menghapus antrian saat ini This will clear the current queue. {track_length} trek akan dihapus\nAnda ingin melanjutkan?", + "load_more": "Lebih Banyak", + "playlists": "Daftar Putar", + "artists": "Artis", + "albums": "Album", + "tracks": "Trek", + "downloads": "Unduhan", + "filter_playlists": "Urutkan daftar putar Anda...", + "liked_tracks": "Lagu Yang Disukai", + "liked_tracks_description": "Semua lagu yang Anda sukai", + "create_playlist": "Buat Daftar Putar", + "create_a_playlist": "Buat daftar putar", + "update_playlist": "Ubah daftar putar", + "create": "Buat", + "cancel": "Batal", + "update": "Ubah", + "playlist_name": "Nama Daftar Putar", + "name_of_playlist": "Nama daftar putar", + "description": "Deskripsi", + "public": "Publik", + "collaborative": "Kolaboratif", + "search_local_tracks": "Cari trek lokal...", + "play": "Putar", + "delete": "Hapus", + "none": "Tidak Ada", + "sort_a_z": "Urutkan berdasarkan A-Z", + "sort_z_a": "Urutkan berdasarkan Z-A", + "sort_artist": "Urutkan berdasarkan Artis", + "sort_album": "Urutkan berdasarkan Album", + "sort_duration": "Urutkan berdasarkan Durasi", + "sort_tracks": "Urutkan trek", + "currently_downloading": "Sedang Mengunduh ({tracks_length})", + "cancel_all": "Batalkan Semua", + "filter_artist": "Urutkan artis...", + "followers": "{followers} Pengikut", + "add_artist_to_blacklist": "Tambah artis ke daftar hitam", + "top_tracks": "Lagu Teratas", + "fans_also_like": "Penggemar juga menyukainya", + "loading": "Memuat...", + "artist": "Artis", + "blacklisted": "Masuk Daftar Hitam", + "following": "Mengikuti", + "follow": "Ikuti", + "artist_url_copied": "URL artis telah disalin", + "added_to_queue": "Menambah trek {tracks} ke antrean", + "filter_albums": "Urutkan album...", + "synced": "Disinkronkan", + "plain": "Normal", + "shuffle": "Acak", + "search_tracks": "Cari trek...", + "released": "Dirilis", + "error": "Kesalahan {error}", + "title": "Judul", + "time": "Waktu", + "more_actions": "Tindakan Lainnya", + "download_count": "Unduhan ({count})", + "add_count_to_playlist": "Menambah ({count}) ke Daftar Putar", + "add_count_to_queue": "Menambah ({count}) ke Antrian", + "play_count_next": "Mainkan ({count}) selanjutnya", + "album": "Album", + "copied_to_clipboard": "{data} telah disalin", + "add_to_following_playlists": "Menambah {track} ke Daftar Putar berikut", + "add": "Tambah", + "added_track_to_queue": "Menambah {track} ke antrian", + "add_to_queue": "Tambah ke antrian", + "track_will_play_next": "{track} akan diputar berikutnya", + "play_next": "Mainkan selanjutnya", + "removed_track_from_queue": "Menghapus {track} dari antrian", + "remove_from_queue": "Hapus dari antrian", + "remove_from_favorites": "Hapus dari favorit", + "save_as_favorite": "Simpan sebagai favorit", + "add_to_playlist": "Tambah ke daftar putar", + "remove_from_playlist": "Hapus dari daftar putar", + "add_to_blacklist": "Tambah ke daftar hitam", + "remove_from_blacklist": "Hapus dari daftar hitam", + "share": "Bagikan", + "mini_player": "Pemutar Mini", + "slide_to_seek": "Geser untuk maju atau mundur", + "shuffle_playlist": "Acak daftar putar", + "unshuffle_playlist": "Batalkan pengacakan daftar putar", + "previous_track": "Lagu sebelumnya", + "next_track": "Lagu berikutnya", + "pause_playback": "Jeda Pemutaran", + "resume_playback": "Lanjutkan Pemutaran", + "loop_track": "Ulangi Pemutaran", + "repeat_playlist": "Ulangi daftar putar", + "queue": "Antrian", + "alternative_track_sources": "Sumber trek alternatif", + "download_track": "Unduh lagu", + "tracks_in_queue": "{tracks} trek dalam antrian", + "clear_all": "Bersihkan semua", + "show_hide_ui_on_hover": "Tampil/Sembunyikan UI saat mengarahkan kursor", + "always_on_top": "Selalu di atas", + "exit_mini_player": "Keluar Pemutar Mini", + "download_location": "Lokasi unduhan", + "account": "Akun", + "login_with_spotify": "Masuk dengan Spotify", + "connect_with_spotify": "Hubungkan dengan Spotify", + "logout": "Keluar", + "logout_of_this_account": "Keluar dari akun", + "language_region": "Bahasa & Wilayah", + "language": "Bahasa", + "system_default": "Bawaan Sistem", + "market_place_region": "Wilayah Pasar", + "recommendation_country": "Negara Rekomendasi", + "appearance": "Tampilan", + "layout_mode": "Mode Tata Letak", + "override_layout_settings": "Ganti pengaturan mode tata letak responsif", + "adaptive": "Adaptif", + "compact": "Ringkas", + "extended": "Diperluas", + "theme": "Tema", + "dark": "Gelap", + "light": "Terang", + "system": "Sistem", + "accent_color": "Warna Aksen", + "sync_album_color": "Sinkronkan warna album", + "sync_album_color_description": "Menggunakan warna dominan sampul album sebagai warna aksen", + "playback": "Pemutaran", + "audio_quality": "Kualitas Suara", + "high": "Tinggi", + "low": "Rendah", + "pre_download_play": "Unduh dan putar", + "pre_download_play_description": "Daripada streaming audio, unduh byte dan mainkan (Direkomendasikan untuk pengguna bandwidth yang lebih tinggi)", + "skip_non_music": "Lewati segmen non-musik (SponsorBlock)", + "blacklist_description": "Lagu dan artis di daftar hitam", + "wait_for_download_to_finish": "Tunggu hingga unduhan saat ini selesai", + "desktop": "Desktop", + "close_behavior": "Tutup Perilaku", + "close": "Tutup", + "minimize_to_tray": "Perkecil ke tray", + "show_tray_icon": "Tampilkan tray ikon sistem", + "about": "Tentang", + "u_love_spotube": "Kami tahu Anda menyukai Spotube", + "check_for_updates": "Periksa pembaruan", + "about_spotube": "Tentang Spotube", + "blacklist": "Daftar Hitam", + "please_sponsor": "Silakan Sponsor/Menyumbang", + "spotube_description": "Spotube, klien Spotify yang ringan, lintas platform, dan gratis untuk semua", + "version": "Versi", + "build_number": "Nomor Pembuatan", + "founder": "Pendiri", + "repository": "Repositori", + "bug_issues": "Bug+Masalah", + "made_with": "Dibuat dengan ❤️ di Bangladesh🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "Lisensi", + "add_spotify_credentials": "Tambahkan kredensial Spotify Anda untuk memulai", + "credentials_will_not_be_shared_disclaimer": "Jangan khawatir, kredensial Anda tidak akan dikumpulkan atau dibagikan kepada siapa pun", + "know_how_to_login": "Tidak tahu bagaimana melakukan ini?", + "follow_step_by_step_guide": "Ikuti panduan Langkah demi Langkah", + "spotify_cookie": "Spotify {name} Cookie", + "cookie_name_cookie": "{name} Cookie", + "fill_in_all_fields": "Silakan isi semua kolom", + "submit": "Kirim", + "exit": "Keluar", + "previous": "Sebelumnya", + "next": "Berikutnya", + "done": "Selesai", + "step_1": "Langkah 1", + "first_go_to": "Pertama, Pergi ke", + "login_if_not_logged_in": "dan Masuk/Daftar jika Anda belum masuk", + "step_2": "Langkah 2", + "step_2_steps": "1. Setelah Anda masuk, tekan F12 atau Klik Kanan Mouse > Buka Browser Devtools.\n2. Lalu buka Tab \"Aplikasi\" (Chrome, Edge, Brave, dll.) atau Tab \"Penyimpanan\" (Firefox, Palemoon, dll.)\n3. Buka bagian \"Cookie\" lalu subbagian \"https://accounts.spotify.com\"", + "step_3": "Langkah 3", + "step_3_steps": "Salin nilai Cookie \"sp_dc\" ", + "success_emoji": "Berhasil🥳", + "success_message": "Sekarang Anda telah berhasil Masuk dengan akun Spotify Anda. Kerja bagus, sobat!", + "step_4": "Langkah 4", + "step_4_steps": "Tempel nilai \"sp_dc\" yang disalin", + "something_went_wrong": "Terjadi kesalahan", + "piped_instance": "Piped Server Instance", + "piped_description": "The Piped server instance untuk digunakan sebagai pencocokan trek", + "piped_warning": "Beberapa di antaranya mungkin tidak berfungsi dengan baik. Jadi gunakan dengan risiko Anda sendiri", + "generate_playlist": "Hasilkan Daftar Putar", + "track_exists": "Lagu {track} sudah ada", + "replace_downloaded_tracks": "Ganti semua trek yang diunduh", + "skip_download_tracks": "Lewati pengunduhan semua trek yang diunduh", + "do_you_want_to_replace": "Apakah Anda ingin mengganti track yang ada?", + "replace": "Ganti", + "skip": "Lewati", + "select_up_to_count_type": "Pilih hingga {count} {type}", + "select_genres": "Pilih Genre", + "add_genres": "Tambah Genre", + "country": "Negara", + "number_of_tracks_generate": "Jumlah trek yang akan dihasilkan", + "acousticness": "Akustik", + "danceability": "Menari", + "energy": "Energi", + "instrumentalness": "Instrumentalitas", + "liveness": "Kehidupan", + "loudness": "Kekerasan", + "speechiness": "Berbicara", + "valence": "Valensi", + "popularity": "Popularitas", + "key": "Kunci", + "duration": "Durasi (s)", + "tempo": "Tempo (BPM)", + "mode": "Mode", + "time_signature": "Tanda Tangan Waktu", + "short": "Pendek", + "medium": "Sedang", + "long": "Panjang", + "min": "Minimal", + "max": "Maksimal", + "target": "Target", + "moderate": "Sedang", + "deselect_all": "Batalkan Semua", + "select_all": "Pilih Semua", + "are_you_sure": "Anda yakin?", + "generating_playlist": "Menghasilkan daftar putar khusus Anda...", + "selected_count_tracks": "{count} lagu yang dipilih", + "download_warning": "Jika Anda mengunduh semua Lagu secara massal, Anda jelas membajak Musik & menyebabkan kerusakan pada masyarakat kreatif Musik. Saya harap Anda menyadari hal ini. Selalu berusaha menghormati & mendukung kerja keras Artis", + "download_ip_ban_warning": "BTW, IP Anda bisa diblokir di YouTube karena permintaan unduhan yang berlebihan dari biasanya. Blokir IP berarti Anda tidak dapat menggunakan YouTube (meskipun Anda masuk) setidaknya selama 2-3 bulan dari perangkat IP tersebut. Dan Spotube tidak bertanggung jawab jika hal ini terjadi", + "by_clicking_accept_terms": "Dengan mengklik 'terima' Anda menyetujui ketentuan berikut:", + "download_agreement_1": "Saya tahu saya membajak Musik. Saya buruk", + "download_agreement_2": "Saya akan mendukung Artis di mana pun saya bisa dan saya melakukan ini hanya karena saya tidak punya uang untuk membeli karya seni mereka", + "download_agreement_3": "Saya sepenuhnya menyadari bahwa IP saya dapat diblokir di YouTube & saya tidak menganggap Spotube atau pemilik/kontributornya bertanggung jawab atas kecelakaan apa pun yang disebabkan oleh tindakan saya saat ini", + "decline": "Menolak", + "accept": "Setuju", + "details": "Detail", + "youtube": "YouTube", + "channel": "Channel", + "likes": "Suka", + "dislikes": "Tidak Suka", + "views": "Dilihat", + "streamUrl": "URL Stream", + "stop": "Berhenti", + "sort_newest": "Urutkan yang baru ditambah", + "sort_oldest": "Urutkan yang paling lama ditambah", + "sleep_timer": "Pengatur Waktu Tidur", + "mins": "{minutes} Menit", + "hours": "{hours} Jam", + "hour": "{hours} Jam", + "custom_hours": "Jam Kostum", + "logs": "Log", + "developers": "Pengembang", + "not_logged_in": "Anda belum masuk", + "search_mode": "Mode Pencarian", + "audio_source": "Sumber Suara", + "ok": "OK", + "failed_to_encrypt": "Gagal mengenkripsi", + "encryption_failed_warning": "Spotube menggunakan enkripsi untuk menyimpan data Anda dengan aman. Namun gagal melakukannya. Jadi itu akan kembali ke penyimpanan yang tidak aman\nJika Anda menggunakan linux, pastikan Anda telah menginstal layanan rahasia (gnome-keyring, kde-wallet, keepassxc, dll)", + "querying_info": "Mencari informasi...", + "piped_api_down": "Piped API tidak aktif", + "piped_down_error_instructions": "Piped Instance {pipedInstance} saat ini tidak aktif\n\nUbah instance atau ubah 'jenis API' menjadi API YouTube resmi\n\nPastikan untuk memulai ulang aplikasi setelah perubahan", + "you_are_offline": "Anda sedang offline", + "connection_restored": "Koneksi internet Anda telah pulih", + "use_system_title_bar": "Gunakan bilah judul sistem", + "crunching_results": "Mengolah hasil...", + "search_to_get_results": "Cari untuk mendapatkan hasil", + "use_amoled_mode": "Tema gelap gulita", + "pitch_dark_theme": "Mode AMOLED", + "normalize_audio": "Normalisasi audio", + "change_cover": "Ganti sampul", + "add_cover": "Tambah sampul", + "restore_defaults": "Kembalikan semula", + "download_music_codec": "Unduh codec musik", + "streaming_music_codec": "Streaming codec musik", + "login_with_lastfm": "Masuk dengan Last.fm", + "connect": "Hubungkan", + "disconnect_lastfm": "Memutuskan Last.fm", + "disconnect": "Memutuskan", + "username": "Username", + "password": "Password", + "login": "Masuk", + "login_with_your_lastfm": "Masuk dengan Last.fm Anda", + "scrobble_to_lastfm": "Scrobble ke Last.fm", + "go_to_album": "Pergi ke Album", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "Lihat Semua", + "genres": "Genre", + "explore_genres": "Jelajahi Genre", + "friends": "Daftar Teman", + "no_lyrics_available": "Maaf, tidak dapat menemukan lirik untuk lagu ini", + "start_a_radio": "Putar Radio", + "how_to_start_radio": "Bagaimana Anda ingin memutar radio?", + "replace_queue_question": "Apakah Anda ingin mengganti antrean saat ini atau menambahkannya?", + "endless_playback": "Pemutaran Tanpa Akhir", + "delete_playlist": "Hapus Daftar Putar", + "delete_playlist_confirmation": "Anda yakin ingin menghapus daftar putar ini?", + "local_tracks": "Trek Lokal", + "song_link": "Tautan Lagu", + "skip_this_nonsense": "Lewati omong kosong ini", + "freedom_of_music": "“Kebebasan Musik”", + "freedom_of_music_palm": "“Kebebasan Musik di telapak tangan Anda”", + "get_started": "Mari kita mulai", + "youtube_source_description": "Direkomendasikan dan berfungsi paling baik.", + "piped_source_description": "Merasa bebas? Sama seperti YouTube tetapi banyak yang gratis.", + "jiosaavn_source_description": "Terbaik untuk wilayah Asia Selatan.", + "highest_quality": "Kualitas Terbaik: {quality}", + "select_audio_source": "Pilih Sumber Suara", + "endless_playback_description": "Tambahkan lagu baru secara otomatis\nke akhir antrean", + "choose_your_region": "Pilih wilayah Anda", + "choose_your_region_description": "Ini akan membantu Spotube menampilkan konten yang tepat\nuntuk lokasi Anda.", + "choose_your_language": "Pilih bahasa Anda", + "help_project_grow": "Bantu proyek ini berkembang", + "help_project_grow_description": "Spotube adalah proyek sumber terbuka. Anda dapat membantu proyek ini berkembang dengan berkontribusi pada proyek, melaporkan bug, atau menyarankan fitur baru.", + "contribute_on_github": "Berkontribusi di GitHub", + "donate_on_open_collective": "Donasi di Open Collective", + "browse_anonymously": "Jelajahi Secara Anonim", + "enable_connect": "Aktifkan Hubungkan", + "enable_connect_description": "Kontrol Spotube dari perangkat lain", + "devices": "Perangkat", + "select": "Pilih", + "connect_client_alert": "Anda dikendalikan oleh {client}", + "this_device": "Perangkat Ini", + "remote": "Remot" +} \ No newline at end of file diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb new file mode 100644 index 00000000..3da06444 --- /dev/null +++ b/lib/l10n/app_ka.arb @@ -0,0 +1,324 @@ +{ + "guest": "სტუმარი", + "browse": "ნახვა", + "search": "ძებნა", + "library": "ბიბლიოთეკა", + "lyrics": "ტექსტები", + "settings": "კონფიგურაციები", + "genre_categories_filter": "კატეგორიების ან ჟანრების ფილტრი...", + "genre": "ჟანრი", + "personalized": "პეერსონალიზებული", + "featured": "გამორჩეული", + "new_releases": "ახალი გამოცემები", + "songs": "სიმღერები", + "playing_track": "უკრავს {track}", + "queue_clear_alert": "ეს გაასუფთავებს მიმდინარე რიგს. {track_length} ტრეკი წაიშლება\nᲒინდა გააგრძელო?", + "load_more": "მეტის ჩატვირთვა", + "playlists": "ფლეილისტები", + "artists": "არტისტები", + "albums": "ალბომები", + "tracks": "ტრეკები", + "downloads": "ჩამოტვირთვები", + "filter_playlists": "ფლეილისტების გაფილტვრა...", + "liked_tracks": "მოწონებული ტრეკები", + "liked_tracks_description": "ყველა შენი მოწონებული ტრეკი", + "create_playlist": "ფლეილისტის შექმნა", + "create_a_playlist": "ფლეილისტის შექმნა", + "update_playlist": "ფლეილისტის განახლება", + "create": "შექმნა", + "cancel": "გაუქმება", + "update": "განახლება", + "playlist_name": "ფლეილისტის სახელი", + "name_of_playlist": "ფლეილისტის სახელი", + "description": "აღწერა", + "public": "საჯარო", + "collaborative": "კოლაბორაციული", + "search_local_tracks": "ლოცალური ტრეკების ძებნა...", + "play": "დაკვრა", + "delete": "წაშლა", + "none": "არცერთი", + "sort_a_z": "დალაგება A-Z-ს მიხედვით", + "sort_z_a": "დალაგება Z-A-ს მიხედვით", + "sort_artist": "დალაგება არტისტის მიხედვით", + "sort_album": "დალაგება ალბომის მიხედვით", + "sort_duration": "დალაგება ხანგრძლივობის მიხედვით", + "sort_tracks": "ტრეკების დალაგება", + "currently_downloading": "მიმდინარეობს ჩამოტვირთვა ({tracks_length})", + "cancel_all": "ყველას გაუქმება", + "filter_artist": "არტისტების ფილტრი...", + "followers": "{followers} ფოლოვერები", + "add_artist_to_blacklist": "არტისტის შავ სიაში დამატება", + "top_tracks": "ტოპ ტრეკები", + "fans_also_like": "ფანებს ასევე მოსწონთ", + "loading": "იტვირთება...", + "artist": "არტისტი", + "blacklisted": "შავ სიაში მყოფი", + "following": "ფოლოვინგი", + "follow": "დაფოლოვება", + "artist_url_copied": "არტისტის ლინკი დაკოპირებულია", + "added_to_queue": "{tracks} ტრეკი დაემატა რიგში", + "filter_albums": "ალბომების გაფილტვრა...", + "synced": "სინქრონიზებული", + "plain": "Plain", + "shuffle": "რიგის არევა", + "search_tracks": "ტრეკების ძებნა...", + "released": "გამოშვებული", + "error": "შეცდომა {error}", + "title": "სათაური", + "time": "დრო", + "more_actions": "მეტი მოქმედებები", + "download_count": "გადმოწერა ({count})", + "add_count_to_playlist": "ფლეილისტში ({count})-ის დამატება", + "add_count_to_queue": "რიგში ({count})-ის დამატება", + "play_count_next": "შემდეგი ({count})-ის დაკვრა", + "album": "ალბომი", + "copied_to_clipboard": "{data} დაკოპირებულია", + "add_to_following_playlists": "დაამატე {track} ამ ფლეილისტებში", + "add": "დამატება", + "added_track_to_queue": "რიგში დაემატა {track}", + "add_to_queue": "რიგში დამატება", + "track_will_play_next": "{track} დაუკრავს შემდეგს", + "play_next": "შემდეგის დაკვრა", + "removed_track_from_queue": "რიგიდან წაიშალა {track}", + "remove_from_queue": "რიგიდან წაშლა", + "remove_from_favorites": "ფავორიტებიდან წაშლა", + "save_as_favorite": "ფავორიტებში დამატება", + "add_to_playlist": "ფლეილისტში დამატება", + "remove_from_playlist": "ფლეილისტიდან წაშლა", + "add_to_blacklist": "შავ სიაში დამატება", + "remove_from_blacklist": "შავი სიიდან წაშლა", + "share": "გაზიარება", + "mini_player": "მინი დამკვრელი", + "slide_to_seek": "გადახვევისთვის გაასრიალეთ წინ ან უკან", + "shuffle_playlist": "ფლეილისტის არევა", + "unshuffle_playlist": "ფლეილისტის დალაგება", + "previous_track": "წინა ტრეკი", + "next_track": "შემდეგი ტრეკი", + "pause_playback": "დაკვრის გაჩერება", + "resume_playback": "დაკვრის გაგრძელება", + "loop_track": "ტრეკის ლუპზე დაკვრა", + "repeat_playlist": "ფლეილისტის გამეორება", + "queue": "რიგი", + "alternative_track_sources": "ალტერნატიული ტრეკების წყაროები", + "download_track": "გადმოწერე ტრეკი", + "tracks_in_queue": "{tracks} ტრეკი რიგში", + "clear_all": "ყველას წაშლა", + "show_hide_ui_on_hover": "UI-ის ჩვენება/დამალვა ჰოვერზე", + "always_on_top": "ტოველთვის ზემოდან", + "exit_mini_player": "მინი დამკვრელიდან გამოსვლა", + "download_location": "ჩამოტვირთვის მდებარეობა", + "account": "ანგარიში", + "login_with_spotify": "შედით თქვენი Spotify ანგარიშით", + "connect_with_spotify": "დაუკავშირდით Spotify-ს", + "logout": "გასვლა", + "logout_of_this_account": "ანგარიშიდან გასვლა", + "language_region": "ენა და რეგიონი", + "language": "ენა", + "system_default": "სისტემის ნაგულისხმევი", + "market_place_region": "მარკეტფლეისის რეგიონი", + "recommendation_country": "რეკომენდირებული ქვეყანა", + "appearance": "გარეგნობა", + "layout_mode": "განლაგების რეჟიმი", + "override_layout_settings": "რესფონსივ განლაგების რეჟიმის კონფიგურაციაზე გადაწერა", + "adaptive": "ადაპტირებული", + "compact": "კომპაქტური", + "extended": "გაფართოებული", + "theme": "თემა", + "dark": "ბნელი", + "light": "ღია", + "system": "სისტემის", + "accent_color": "აქცენტის ფერი", + "sync_album_color": "ალბომის ფერის სინქრონიზაცია", + "sync_album_color_description": "დომინანტური ალბომის ფერის აქცენტის ფერად გამოყენება", + "playback": "დაკვრა", + "audio_quality": "აუდიოს ხარისხი", + "high": "მაღალი", + "low": "დაბალი", + "pre_download_play": "წინასწარ ჩამოტვირთვა და დაკვრა", + "pre_download_play_description": "აუდიოს სტრიმინგის ნაცვლად, ბაიტების ჩამოტვირთვა და დაკვრა (რეკომენდებულია უფრო მაღალი გამტარუნარიანობის მომხმარებლებისთვის)", + "skip_non_music": "არა მუსიკალური ნაწილის გამოტოვება (სპონსორის ბლოკი)", + "blacklist_description": "შავ სიაში მყოფი არტისტები და ტრეკები", + "wait_for_download_to_finish": "გთხოვთ, დაელოდოთ მიმდინარე ჩამოტვირთვის დასრულებას", + "desktop": "დესკტოპი", + "close_behavior": "დახურვის ქცევა", + "close": "დახურვა", + "minimize_to_tray": "მინიმიზაცია", + "show_tray_icon": "სისტემის აიკონის ჩვენება", + "about": "ჩვენს შესახებ", + "u_love_spotube": "We know you love Spotube", + "check_for_updates": "განახლებების შემოწმება", + "about_spotube": "Spotube-ს შესახებ", + "blacklist": "შავი სია", + "please_sponsor": "გთხოვთ დაგვასპონსოროთ", + "spotube_description": "Spotube, a lightweight, cross-platform, free-for-all spotify client", + "version": "ვერსია", + "build_number": "Build Number", + "founder": "დამფუძნებელი", + "repository": "რეპოზიტორია", + "bug_issues": "Bug+Issues", + "made_with": "Made with ❤️ in Bangladesh🇧🇩", + "kingkor_roy_tirtho": "Kingkor Roy Tirtho", + "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", + "license": "ლიცენზია", + "add_spotify_credentials": "დასაწყებად დაამატეთ თქვენი Spotify მონაცემები", + "credentials_will_not_be_shared_disclaimer": "არ ინერვიულოთ, თქვენი მონაცემები არ იქნება შეგროვებული ან გაზიარებული ვინმესთან", + "know_how_to_login": "არ იცით როგორ გააკეთოთ ეს?", + "follow_step_by_step_guide": "მიჰყევით ნაბიჯ-ნაბიჯ სახელმძღვანელოს", + "spotify_cookie": "Spotify {name} ქუქი", + "cookie_name_cookie": "{name} ქუქი", + "fill_in_all_fields": "გთხოვთ შეავსოთ ყველა ველი", + "submit": "გაგზავნა", + "exit": "გამოსვლა", + "previous": "წინა", + "next": "შემდეგი", + "done": "მზადაა", + "step_1": "ნაბიჯი 1", + "first_go_to": "პირველი, გადადით", + "login_if_not_logged_in": "და შესვლა/რეგისტრაცია, თუ არ ხართ შესული", + "step_2": "ნაბიჯი 2", + "step_2_steps": "1. როცა შეხვალთ, დააჭირეთ F12-ს ან მაუსის მარჯვენა ღილაკს > Inspect to Open the Browser devtools.\n2. შემდეგ გახსენით \"Application\" განყოფილება (Chrome, Edge, Brave etc..) ან \"Storage\" განყოფილება (Firefox, Palemoon etc..)\n3. შედით \"Cookies\" სექციაში და შემდეგ \"https://accounts.spotify.com\" სუბსექციაში", + "step_3": "ნაბიჯი 3", + "step_3_steps": "დააკოპირეთ \"sp_dc\" ქუქი-ფაილის მნიშვნელობა", + "success_emoji": "წარმატება🥳", + "success_message": "თქვენ წარმატებით შეხვედით თქვენი Spotify ანგარიშით.", + "step_4": "ნაბიჯი 4", + "step_4_steps": "ჩასვით კოპირებული \"sp_dc\" მნიშვნელობა", + "something_went_wrong": "Რაღაც არასწორად წავიდა", + "piped_instance": "Piped Server Instance", + "piped_description": "The Piped server instance to use for track matching", + "piped_warning": "ზოგიერთი მათგანმა შეიძლება კარგად არ იმუშაოს. ", + "generate_playlist": "ფლეილისტის დაგენერირება", + "track_exists": "ტრეკი {track} უკვე არსებობს", + "replace_downloaded_tracks": "ყველა ჩამოტვირთული ტრეკის შეცვლა", + "skip_download_tracks": "ყველა ჩამოტვირთული ტრეკის გამოტოვება", + "do_you_want_to_replace": "გსურთ შეცვალოთ არსებული ტრეკი??", + "replace": "შეცვლა", + "skip": "გამოტოვება", + "select_up_to_count_type": "აირჩიე {count}-მდე {type}", + "select_genres": "ჟანრების არჩევა", + "add_genres": "ჟანრების დამატება", + "country": "ქვეყანა", + "number_of_tracks_generate": "დასაგენერირებელი ტრეკების რაოდენობა", + "acousticness": "Acousticness", + "danceability": "Danceability", + "energy": "Energy", + "instrumentalness": "Instrumentalness", + "liveness": "Liveness", + "loudness": "Loudness", + "speechiness": "Speechiness", + "valence": "Valence", + "popularity": "Popularity", + "key": "Key", + "duration": "Duration (s)", + "tempo": "Tempo (BPM)", + "mode": "Mode", + "time_signature": "Time Signature", + "short": "Short", + "medium": "საშუალო", + "long": "გრძელი", + "min": "მინიმალური", + "max": "მაქსიმალური", + "target": "სამიზნე", + "moderate": "საშუალო", + "deselect_all": "ყველა მონიშვნის გაუქმება", + "select_all": "ყველას მონიშვნა", + "are_you_sure": "Დარწმუნებული ხართ?", + "generating_playlist": "მიმდინარეობს თქვენი მორგებული ფლეილისტის გენერირება...", + "selected_count_tracks": "არჩეულია {count} ტრეკი", + "download_warning": "If you download all Tracks at bulk you're clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist's hard work", + "download_ip_ban_warning": "BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can't use YouTube (even if you're logged in) for at least 2-3 months from that IP device. And Spotube doesn't hold any responsibility if this ever happens", + "by_clicking_accept_terms": "By clicking 'accept' you agree to following terms:", + "download_agreement_1": "I know I'm pirating Music. I'm bad", + "download_agreement_2": "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art", + "download_agreement_3": "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action", + "decline": "უარყოფა", + "accept": "დათანხმება", + "details": "დეტალები", + "youtube": "YouTube", + "channel": "Channel", + "likes": "მოწონებები", + "dislikes": "არ მოწონებები", + "views": "ნახვები", + "streamUrl": "სტრიმის ლინკი", + "stop": "გაჩერება", + "sort_newest": "ფალაგება სიახლის მიხედიტ", + "sort_oldest": "დალაგება სიძველის მიხედვით", + "sleep_timer": "ძილის ტაიმერი", + "mins": "{minutes} წუთი", + "hours": "{hours} საათი", + "hour": "{hours} საათი", + "custom_hours": "მორგებული საათები", + "logs": "ლოგები", + "developers": "დეველოპერები", + "not_logged_in": "არ ხარ დალოგინებული", + "search_mode": "ძებნის რეჟიმი", + "audio_source": "აუდიოს წყარო", + "ok": "ოკ", + "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", + "querying_info": "Querying info...", + "piped_api_down": "Piped API is down", + "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change", + "you_are_offline": "ამჟამად ხაზგარეშე ხართ", + "connection_restored": "თქვენი ინტერნეტ კავშირი აღდგა", + "use_system_title_bar": "სისტემის სათაურის ზოლის გამოყენება", + "crunching_results": "იტვირთება შედეგები...", + "search_to_get_results": "მოძებნეთ შედეგების მისაღებად", + "use_amoled_mode": "Pitch black dark theme", + "pitch_dark_theme": "AMOLED Mode", + "normalize_audio": "აუდიოს ნორმალიზება", + "change_cover": "Ქავერის შეცვლა", + "add_cover": "Ქავერის ფოტოს დამატება", + "restore_defaults": "ნაგულისხმევი პარამეტრების აღდგენა", + "download_music_codec": "მუსიკის კოდეკის გადმოწერა", + "streaming_music_codec": "სტრიმინგ მუსიკის კოდეკი", + "login_with_lastfm": "Last.fm-ით შესვლა", + "connect": "დაკავშირება", + "disconnect_lastfm": "Last.fm-იდან გამოსვლა", + "disconnect": "გამოსვლა", + "username": "მომხმარებელი", + "password": "პაროლი", + "login": "შესვლა", + "login_with_your_lastfm": "Last.fm ანგარიშით შესვლა", + "scrobble_to_lastfm": "Scrobble to Last.fm", + "go_to_album": "ალბომზე გადასვლა", + "discord_rich_presence": "Discord Rich Presence", + "browse_all": "ყველას ნახვა", + "genres": "ჟანრები", + "explore_genres": "შეისწავლეთ ჟანრები", + "friends": "მეგობრები", + "no_lyrics_available": "უკაცრავად, ამ ტრეკისთვის ტექსტის პოვნა შეუძლებელია", + "start_a_radio": "რადიოს ჩართვა", + "how_to_start_radio": "როგორ გნებავთ რადიოს ჩართვა?", + "replace_queue_question": "გნებავთ ჩაანაცვლოთ არსებული რიგი თუ დაამატოთ მასზე?", + "endless_playback": "დაუსრულებელი დაკვრა", + "delete_playlist": "ფლეილისტის წაშლა", + "delete_playlist_confirmation": "დარწმუნებული ხართ რომ გნებავთ ფლეილისტის წაშლა?", + "local_tracks": "ლოკალური ტრეკები", + "song_link": "ტრეკის ლინკი", + "skip_this_nonsense": "ამ სისულელის გამოტოვება", + "freedom_of_music": "“მუსიკის თავისუფლება”", + "freedom_of_music_palm": "“მუსიკის თავისუფლება შენს ხელის გულზე”", + "get_started": "დავიწყოთ", + "youtube_source_description": "რეკომენდებულია და მუშაობს საუკეთესოდ.", + "piped_source_description": "თავისუფლად გრძნობთ თავს? იგივეა, რაც YouTube, მაგრამ ბევრი თავისუფალი.", + "jiosaavn_source_description": "საუკეთესოა სამხრეთ აზიის რეგიონისთვის.", + "highest_quality": "საუკეთესო ხარისხი: {quality}", + "select_audio_source": "აუდიოს წყაროს არჩევა", + "endless_playback_description": "ახალი სიმთერების ავტომატურად რიგის ბოლოში დამატება", + "choose_your_region": "აირჩიე შენი რეგიონი", + "choose_your_region_description": "This will help Spotube show you the right content\nfor your location.", + "choose_your_language": "აირჩიე ენა", + "help_project_grow": "დაეხმარეთ ამ პროექტს განვითარებაში", + "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", + "contribute_on_github": "GitHub-ზე კონტრიბუცია", + "donate_on_open_collective": "Open Collective-ზე დონაცია", + "browse_anonymously": "ანონიმურად ნახვა", + "enable_connect": "დაკავშირების ჩართვა", + "enable_connect_description": "აკონტროლე Spotube სხვა მოწყობილობებიდან", + "devices": "მოწყობილობები", + "select": "არჩევა", + "connect_client_alert": "თქვენ კონტროლირებული ხართ {client} მოწყობილობით", + "this_device": "ეს მოწყობილობა", + "remote": "დისტანციური" +} \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index a4050853..aab6bc6d 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -3,13 +3,13 @@ "browse": "Göz at", "search": "Ara", "library": "Kütüphane", - "lyrics": "Şarkı Sözleri", + "lyrics": "Şarkı sözleri", "settings": "Ayarlar", - "genre_categories_filter": "Kategorileri veya türleri filtrele...", + "genre_categories_filter": "Kategorileri veya türleri filtreleyin...", "genre": "Tür", "personalized": "Kişiselleştirilmiş", - "featured": "Öne Çıkanlar", - "new_releases": "Yeni Çıkanlar", + "featured": "Öne çıkanlar", + "new_releases": "Yeni çıkanlar", "songs": "Şarkılar", "playing_track": "{track} oynatılıyor", "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parça kaldırılacak\nDevam etmek istiyor musunuz?", @@ -20,15 +20,15 @@ "tracks": "Parçalar", "downloads": "İndirilenler", "filter_playlists": "Oynatma listelerinizi filtreleyin...", - "liked_tracks": "Beğenilen Parçalar", + "liked_tracks": "Beğenilen parçalar", "liked_tracks_description": "Beğendiğiniz tüm parçalar", - "create_playlist": "Oynatma Listesi Oluştur", - "create_a_playlist": "Bir oynatma listesi oluşturun", + "create_playlist": "Oynatma listesi oluştur", + "create_a_playlist": "Bir oynatma listesi oluştur", "update_playlist": "Oynatma listesini güncelle", "create": "Oluştur", "cancel": "İptal", "update": "Güncelle", - "playlist_name": "Oynatma Listesi Adı", + "playlist_name": "Oynatma listesi adı", "name_of_playlist": "Oynatma listesinin adı", "description": "Açıklama", "public": "Halka açık", @@ -39,16 +39,16 @@ "none": "Yok", "sort_a_z": "A - Z'ye göre sırala", "sort_z_a": "Z - A'ya göre sırala", - "sort_artist": "Sanatçıya Göre Sırala", - "sort_album": "Albüme Göre Sırala", - "sort_duration": "Süreye Göre Sırala", - "sort_tracks": "Parçaları Sırala", - "currently_downloading": "Şu An İndirilenler ({tracks_length})", - "cancel_all": "Tümünü İptal Et", - "filter_artist": "Sanatçıları filtrele...", + "sort_artist": "Sanatçıya göre sırala", + "sort_album": "Albüme göre sırala", + "sort_duration": "Süreye göre sırala", + "sort_tracks": "Parçaları sırala", + "currently_downloading": "Şu anda indirilenler ({tracks_length})", + "cancel_all": "Tümünü iptal et", + "filter_artist": "Sanatçıları filtreleyin...", "followers": "{followers} Takipçiler", "add_artist_to_blacklist": "Sanatçıyı kara listeye ekle", - "top_tracks": "En İyi Parçalar", + "top_tracks": "En iyi parçalar", "fans_also_like": "Hayranlar ayrıca şunları da beğendi", "loading": "Yükleniyor...", "artist": "Sanatçı", @@ -57,7 +57,7 @@ "follow": "Takip et", "artist_url_copied": "Sanatçı bağlantısı panoya kopyalandı", "added_to_queue": "Kuyruğa {tracks} parçası eklendi", - "filter_albums": "Albümleri filtrele...", + "filter_albums": "Albümleri filtreleyin...", "synced": "Senkronize edildi", "plain": "Sade", "shuffle": "Karıştır", @@ -68,19 +68,19 @@ "time": "Zaman", "more_actions": "Daha fazla eylem", "download_count": "İndir ({count})", - "add_count_to_playlist": "Oynatma Listesine ({count}) ekle", - "add_count_to_queue": "Kuyruğa ({count}) ekle", - "play_count_next": "({count}) sonrakini oynat", + "add_count_to_playlist": "Oynatma Listesine ekle ({count})", + "add_count_to_queue": "Kuyruğa ekle ({count})", + "play_count_next": "Sonrakini oynat ({count})", "album": "Albüm", "copied_to_clipboard": "{data} panoya kopyalandı", - "add_to_following_playlists": "{track} parçasını aşağıdaki Oynatma Listelerine ekle", + "add_to_following_playlists": "{track} parçasını aşağıdaki oynatma listelerine ekle", "add": "Ekle", "added_track_to_queue": "{track} kuyruğa eklendi", "add_to_queue": "Kuyruğa ekle", "track_will_play_next": "{track} bir sonraki çalacak", "play_next": "Sonrakini oynat", - "removed_track_from_queue": "{track} sıradan kaldırıldı", - "remove_from_queue": "Sıradan kaldır", + "removed_track_from_queue": "{track} kuyruktan kaldırıldı", + "remove_from_queue": "Kuyruktan kaldır", "remove_from_favorites": "Favorilerden kaldır", "save_as_favorite": "Favori olarak kaydet", "add_to_playlist": "Oynatma listesine ekle", @@ -88,7 +88,7 @@ "add_to_blacklist": "Kara listeye ekle", "remove_from_blacklist": "Kara listeden kaldır", "share": "Paylaş", - "mini_player": "Mini Oynatıcı", + "mini_player": "Mini oynatıcı", "slide_to_seek": "İleri veya geri arama yapmak için kaydırın", "shuffle_playlist": "Oynatma listesini karıştır", "unshuffle_playlist": "Oynatma listesinin karışıklığını kaldır", @@ -98,27 +98,27 @@ "resume_playback": "Oynatmayı sürdür", "loop_track": "Döngü parçası", "repeat_playlist": "Oynatma listesini tekrarla", - "queue": "Sıra", - "alternative_track_sources": "Alternatif yol kaynakları", + "queue": "Kuyruk", + "alternative_track_sources": "Alternatif parça kaynakları", "download_track": "Parçayı indir", - "tracks_in_queue": "{tracks} parça sırada", + "tracks_in_queue": "{tracks} parça kuyrukta", "clear_all": "Tümünü temizle", "show_hide_ui_on_hover": "Fareyle üzerine gelindiğinde kullanıcı arayüzünü göster/gizle", "always_on_top": "Her zaman üstte", "exit_mini_player": "Mini oynatıcıdan çık", "download_location": "İndirme konumu", "account": "Hesap", - "login_with_spotify": "Spotify hesabınızla giriş yapın", + "login_with_spotify": "Spotify hesabı ile giriş yap", "connect_with_spotify": "Spotify ile bağlan", - "logout": "Çıkış Yap", - "logout_of_this_account": "Bu hesaptan çıkış yap", - "language_region": "Dil ve Bölge", - "language": "Dil", - "system_default": "Sistem Varsayılanı", - "market_place_region": "Pazaryeri Bölgesi", - "recommendation_country": "Tavsiye Edilen Ülke", + "logout": "Çıkış yap", + "logout_of_this_account": "Hesaptan çıkış yap", + "language_region": "Dil ve bölge", + "language": "Tercih edilen dil", + "system_default": "Sistem varsayılanı", + "market_place_region": "Tercih edilen bölge", + "recommendation_country": "Tavsiye edilen ülke", "appearance": "Görünüm", - "layout_mode": "Düzen Modu", + "layout_mode": "Düzen modu", "override_layout_settings": "Duyarlı düzen modu ayarlarını geçersiz kıl", "adaptive": "Uyarlanabilir", "compact": "Sıkıştırılmış", @@ -127,35 +127,35 @@ "dark": "Koyu", "light": "Açık", "system": "Sistem", - "accent_color": "Vurgu Rengi", + "accent_color": "Vurgu rengi", "sync_album_color": "Albüm rengini senkronize et", "sync_album_color_description": "Vurgu rengi olarak albüm resminin baskın rengini kullanır", "playback": "Oynatma", - "audio_quality": "Ses Kalitesi", + "audio_quality": "Ses kalitesi", "high": "Yüksek", "low": "Düşük", - "pre_download_play": "Ön yükleme ve oynatma", - "pre_download_play_description": "Ses akışı yerine baytları indirin ve oynatın (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)", - "skip_non_music": "Müzik olmayan bölümleri atla (SponsorBlock)", + "pre_download_play": "Önceden indir ve oynat", + "pre_download_play_description": "Ses akışı yerine baytları indir ve oynat (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)", + "skip_non_music": "Müzik olmayan bölümleri atlat (SponsorBlock)", "blacklist_description": "Kara listeye alınan parçalar ve sanatçılar", "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin tamamlanmasını bekleyin", "desktop": "Masaüstü", - "close_behavior": "Kapatma Davranışı", + "close_behavior": "Kapatma davranışı", "close": "Kapat", "minimize_to_tray": "Tepsiye küçült", "show_tray_icon": "Sistem tepsisi simgesini göster", "about": "Hakkında", "u_love_spotube": "Spotube'u sevdiğinizi biliyoruz", "check_for_updates": "Güncellemeleri kontrol et", - "about_spotube": "Spotube Hakkında", + "about_spotube": "Spotube hakkında", "blacklist": "Kara liste", "please_sponsor": "Sponsor Ol/Bağış Yap", - "spotube_description": "Spotube, hafif, platformlar arası, herkes için ücretsiz bir spotify istemcisidir", + "spotube_description": "Spotube, hafif, platformlar arası uyumlu ve herkes için ücretsiz bir Spotify istemcisidir.", "version": "Sürüm", - "build_number": "Derleme Numarası", - "founder": "Kurucu", + "build_number": "Derleme numarası", + "founder": "Geliştirici", "repository": "Depo", - "bug_issues": "Hata+Sorunlar", + "bug_issues": "Hata + Sorunlar", "made_with": "❤️ ile Bangladeş'te yapıldı", "kingkor_roy_tirtho": "Kingkor Roy Tirtho", "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", @@ -163,31 +163,31 @@ "add_spotify_credentials": "Başlamak için spotify kimlik bilgilerinizi ekleyin", "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, kimlik bilgilerinizden hiçbiri toplanmayacak veya kimseyle paylaşılmayacak", "know_how_to_login": "Bunu nasıl yapacağınızı bilmiyor musunuz?", - "follow_step_by_step_guide": "Adım Adım kılavuzu takip edin", - "spotify_cookie": "Spotify {name} Çerezi", - "cookie_name_cookie": "{name} Çerezi", + "follow_step_by_step_guide": "Adım adım kılavuzu takip edin", + "spotify_cookie": "Spotify {name} çerezi", + "cookie_name_cookie": "{name} çerezi", "fill_in_all_fields": "Lütfen tüm alanları doldurun", - "submit": "Gönder", + "submit": "Başvur", "exit": "Çık", "previous": "Önceki", "next": "Sonraki", "done": "Bitti", "step_1": "1. Adım", "first_go_to": "İlk olarak şuraya gidin:", - "login_if_not_logged_in": "ve oturum açmadıysanız Oturum Açın/Kaydolun", + "login_if_not_logged_in": "ve oturum açmadıysanız Oturum açın/Kaydolun", "step_2": "2. Adım", - "step_2_steps": "1. Giriş yaptıktan sonra, Tarayıcı geliştirme araçlarını açmak için F12 veya Fareye Sağ Tıklayın > İncele'ye basın.\n2. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin.\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin", + "step_2_steps": "1. Oturum açtıktan sonra, tarayıcı geliştirme araçlarını açmak için F12'ye veya fareye sağ tıklayın > İncele'ye basın.\n2. Daha sonra \"Uygulama\" sekmesine (Chrome, Edge, Brave vb..) veya \"Depolama\" sekmesine (Firefox, Palemoon vb..) gidin\n3. \"Çerezler\" bölümüne, ardından \"https://accounts.spotify.com\" alt bölümüne gidin", "step_3": "3. Adım", "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyalayın", "success_emoji": "Başarılı🥳", - "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Aferin, dostum!", + "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Tebrik ederim!", "step_4": "4. Adım", "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştırın", "something_went_wrong": "Bir hata oluştu", - "piped_instance": "Piped Sunucu Örneği", + "piped_instance": "Piped sunucu örneği", "piped_description": "Parça eşleştirme için kullanılacak Piped sunucu örneği", "piped_warning": "Bazıları iyi çalışmayabilir. Yani riski size ait olmak üzere kullanın", - "generate_playlist": "Oynatma Listesi Oluştur", + "generate_playlist": "Oynatma listesi oluştur", "track_exists": "{track} parçası zaten var", "replace_downloaded_tracks": "İndirilen tüm parçaları değiştir", "skip_download_tracks": "İndirilen tüm parçaları indirmeyi atla", @@ -195,8 +195,8 @@ "replace": "Değiştir", "skip": "Atla", "select_up_to_count_type": "En fazla {count} {type} seçin", - "select_genres": "Türleri Seç", - "add_genres": "Tür Ekle", + "select_genres": "Türleri seç", + "add_genres": "Tür ekle", "country": "Ülke", "number_of_tracks_generate": "Oluşturulacak parça sayısı", "acousticness": "Akustiklik", @@ -212,7 +212,7 @@ "duration": "Süre (sn)", "tempo": "Tempo (BPM)", "mode": "Mod", - "time_signature": "Zaman İmzası", + "time_signature": "Zaman imzası", "short": "Kısa", "medium": "Orta", "long": "Uzun", @@ -220,29 +220,29 @@ "max": "Maks", "target": "Hedef", "moderate": "Orta", - "deselect_all": "Tüm Seçimleri Kaldır", - "select_all": "Tümünü Seç", + "deselect_all": "Tüm seçimleri kaldır", + "select_all": "Tümünü seç", "are_you_sure": "Emin misiniz?", "generating_playlist": "Özel oynatma listeniz oluşturuluyor...", "selected_count_tracks": "{count} parça seçildi", - "download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapıyor ve Müziğin yaratıcı toplumuna zarar veriyorsunuz demektir. Umarım bunun farkındasınızdır. Her zaman, Sanatçının sıkı çalışmasına saygı duymaya ve desteklemeye çalışın", - "download_ip_ban_warning": "Bu arada, IP'niz normalden daha fazla indirme isteği nedeniyle YouTube'da engellenebilir. IP engelleme, YouTube'u (oturum açmış olsanız bile) o IP cihazından en az 2 -3 ay kullanamayacağınız anlamına gelir. Ve eğer böyle bir şey olursa Spotube'un hiçbir sorumluluğu yok", + "download_warning": "Tüm şarkıları toplu olarak indiriyorsanız, açıkça müzik korsanlığı yapıyorsunuz ve müzik dünyasının yaratıcı topluluğuna zarar veriyorsunuz demektir. Umuyorum bunun farkındasınızdır. Her zaman, sanatçıların emeğine saygı göstermeyi ve desteklemeyi deneyin.", + "download_ip_ban_warning": "Ayrıca, normalden fazla indirme istekleri nedeniyle YouTube'da IP'niz engellenebilir. IP engeli, en az 2-3 ay boyunca YouTube'u (hatta oturum açmış olsanız bile) o IP cihazından kullanamayacağınız anlamına gelir. Ve eğer böyle bir durum yaşanırsa, Spotube bundan hiçbir sorumluluk kabul etmez.", "by_clicking_accept_terms": "\"Kabul et\" e tıklayarak aşağıdaki şartları kabul etmiş olursunuz:", - "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben kötüyüm", + "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben fakir biriyim.", "download_agreement_2": "Sanatçıyı elimden geldiğince destekleyeceğim ve bunu sadece sanatını satın alacak param olmadığı için yapıyorum", - "download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut işlemimden kaynaklanan herhangi bir kazadan Spotube'u veya sahiplerini/katkıda bulunanlarını sorumlu tutmuyorum", + "download_agreement_3": "YouTube'da IP'min engellenebileceğinin tamamen farkındayım ve mevcut eylemlerimden kaynaklanan herhangi bir kaza için Spotube'u veya sahiplerini/katkıda bulunanları sorumlu tutmuyorum.", "decline": "Reddet", "accept": "Kabul et", "details": "Detaylar", "youtube": "YouTube", "channel": "Kanal", - "likes": "Beğeniler", + "likes": "Beğenenler", "dislikes": "Beğenmeyenler", "views": "İzlenmeler", "streamUrl": "Akış bağlantısı", "stop": "Durdur", - "sort_newest": "En yeniye göre sırala", - "sort_oldest": "Eklenen en eskiye göre sırala", + "sort_newest": "En yeni eklenene göre sırala.", + "sort_oldest": "En eski eklenene göre sırala", "sleep_timer": "Uyku Zamanlayıcısı", "mins": "{minutes} Dakika", "hours": "{hours} Saatler", @@ -251,11 +251,11 @@ "logs": "Günlükler", "developers": "Geliştiriciler", "not_logged_in": "Giriş yapmadınız", - "search_mode": "Arama Modu", - "audio_source": "Ses Kaynağı", + "search_mode": "Arama modu", + "audio_source": "Ses kaynağı", "ok": "Tamam", "failed_to_encrypt": "Şifreleme başarısız oldu", - "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde saklamak için şifreleme kullanır. Ama başaramadı. Bu yüzden güvensiz depolamaya geri dönecek\nLinux kullanıyorsanız, lütfen herhangi bir gizli servisin (gnome - anahtarlık, kde - cüzdan, keepassxc vb.) yüklü olduğundan emin olun", + "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde depolamak için şifreleme kullanır. Ancak bunu başaramadı. Bu nedenle, güvensiz depolamaya geri dönecektir\nLinux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. herhangi bir gizli servisin yüklü olduğundan emin olun.", "querying_info": "Bilgi sorgulanıyor...", "piped_api_down": "Piped API kapalı", "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nÖrneği değiştirin veya 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun", @@ -263,8 +263,8 @@ "connection_restored": "İnternet bağlantınız geri yüklendi", "use_system_title_bar": "Sistem başlık çubuğunu kullan", "crunching_results": "Sonuçlar...", - "search_to_get_results": "Sonuç almak için ara", - "use_amoled_mode": "AMOLED Modunu Kullan", + "search_to_get_results": "Sonuç almak için arayın", + "use_amoled_mode": "AMOLED modu kullan", "pitch_dark_theme": "Zifiri karanlık koyu tema", "normalize_audio": "Sesi normalleştir", "change_cover": "Kapağı değiştir", @@ -277,48 +277,48 @@ "disconnect_lastfm": "Last.fm bağlantısını kes", "disconnect": "Bağlantıyı kes", "username": "Kullanıcı adı", - "password": "Parola", - "login": "Giriş", + "password": "Şifre", + "login": "Giriş yap", "login_with_your_lastfm": "Last.fm hesabınızla giriş yapın", "scrobble_to_lastfm": "Last.fm için Scrobble", - "go_to_album": "Albüme Git", - "discord_rich_presence": "Discord Zengin Varlığı", - "browse_all": "Tümüne Göz At", - "genres": "Müzik Türleri", - "explore_genres": "Türleri Keşfet", + "go_to_album": "Albüme git", + "discord_rich_presence": "Discord zengin varlığı", + "browse_all": "Tümüne göz at", + "genres": "Müzik türleri", + "explore_genres": "Türleri keşfet", "friends": "Arkadaşlar", "no_lyrics_available": "Üzgünüz, bu parçanın sözleri bulunamıyor", - "start_a_radio": "Radyo Başlat", + "start_a_radio": "Radyo başlat", "how_to_start_radio": "Radyoyu nasıl başlatmak istersiniz?", "replace_queue_question": "Mevcut kuyruğu değiştirmek mi yoksa eklemek mi istersiniz?", - "endless_playback": "Sonsuz Olarak Oynat", - "delete_playlist": "Oynatma Listesini Sil", + "endless_playback": "Sonsuz olarak oynat", + "delete_playlist": "Oynatma listesini sil", "delete_playlist_confirmation": "Bu oynatma listesini silmek istediğinizden emin misiniz?", - "local_tracks": "Yerel Parçalar", - "song_link": "Şarkı Bağlantısı", + "local_tracks": "Yerel parçalar", + "song_link": "Şarkı bağlantısı", "skip_this_nonsense": "Bu saçmalığı atla", - "freedom_of_music": "“Müzik Özgürlüğü”", - "freedom_of_music_palm": "“Müzik Özgürlüğü avucunuzun içinde”", + "freedom_of_music": "“Müzik özgürlüğü”", + "freedom_of_music_palm": "“Müzik özgürlüğü avucunuzun içinde”", "get_started": "Haydi başlayalım", "youtube_source_description": "Tavsiye edilir ve en iyi şekilde çalışır.", - "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı ama çok daha fazla ücretsiz.", + "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı, ama çok daha özgür.", "jiosaavn_source_description": "Güney Asya bölgesi için en iyisi.", - "highest_quality": "En Yüksek Kalite: {quality}", - "select_audio_source": "Ses Kaynağını Seç", - "endless_playback_description": "Yeni şarkıları otomatik olarak \nkuyruğun sonuna ekle", + "highest_quality": "En yüksek kalite: {quality}", + "select_audio_source": "Ses kaynağını seçin", + "endless_playback_description": "Yeni şarkıları otomatik olarak\nkuyruğun sonuna ekle", "choose_your_region": "Bölgenizi seçin", - "choose_your_region_description": "Bu, Spotube'un size doğru içeriği göstermesine yardımcı olacaktır\nkonumunuz için.", + "choose_your_region_description": "Bu, Spotube'un konumunuza uygun içerikleri göstermesine yardımcı olacaktır.", "choose_your_language": "Dilinizi seçin", - "help_project_grow": "Bu projenin büyümesine yardımcı ol", + "help_project_grow": "Bu projenin büyümesine yardımcı olun", "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.", - "contribute_on_github": "GitHub'a katkıda bulunun", - "donate_on_open_collective": "Open Collective'e bağış yap", - "browse_anonymously": "Anonim Olarak Göz at", - "enable_connect": "Bağlantıyı Etkinleştir", + "contribute_on_github": "GitHub'da katkıda bulun", + "donate_on_open_collective": "Open Collective'de bağış yap", + "browse_anonymously": "Anonim olarak giriş yap", + "enable_connect": "Bağlanmayı etkinleştir", "enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin", "devices": "Cihazlar", "select": "Seç", "connect_client_alert": "{client} tarafından kontrol ediliyorsun.", - "this_device": "Bu Cihaz", + "this_device": "Bu cihaz", "remote": "Yönet" } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index ef3685fa..ebdc4b61 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -7,7 +7,7 @@ /// TexturedPolak@github => Polish /// yuri-val@github => Ukrainian /// energywave@github, ncvescera@github, OpenCode@github => Italian -/// mdksec@github, mikropsoft@github => Turkish +/// mikropsoft@github => Turkish /// Stephan-P@github, SecularSteve@github => Dutch /// doannc2212@github => Vietnamese /// sappho192@github => Korean @@ -28,11 +28,14 @@ class L10n { const Locale('de', 'GE'), const Locale('es', 'ES'), const Locale('fa', 'IR'), + const Locale('fi', 'FI'), const Locale('fr', 'FR'), const Locale('ne', 'NP'), const Locale('hi', 'IN'), + const Locale('id', 'ID'), const Locale('it', 'IT'), const Locale('ja', 'JP'), + const Locale('ka', 'GE'), const Locale('ko', 'KR'), const Locale('nl', 'NL'), const Locale('pl', 'PL'), @@ -43,5 +46,6 @@ class L10n { const Locale('tr', 'TR'), const Locale('zh', 'CN'), const Locale('vi', 'VN'), + const Locale('eu', 'ES'), ]; } diff --git a/lib/main.dart b/lib/main.dart index 0bb72932..1693d9d8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,15 @@ import 'package:catcher_2/catcher_2.dart'; import 'package:dart_discord_rpc/dart_discord_rpc.dart'; -import 'package:device_preview/device_preview.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:local_notifier/local_notifier.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'; @@ -19,6 +17,7 @@ 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/provider/tray_manager/tray_manager.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; @@ -31,15 +30,17 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/utils/platform.dart'; import 'package:system_theme/system_theme.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:spotube/hooks/configurators/use_init_sys_tray.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:timezone/data/latest.dart' as tz; +import 'package:window_manager/window_manager.dart'; Future main(List rawArgs) async { final arguments = await startCLI(rawArgs); @@ -55,12 +56,12 @@ Future main(List rawArgs) async { MediaKit.ensureInitialized(); // force High Refresh Rate on some Android devices (like One Plus) - if (DesktopTools.platform.isAndroid) { + if (kIsAndroid) { await FlutterDisplayMode.setHighRefreshRate(); } - if (DesktopTools.platform.isDesktop) { - await DesktopTools.window.setPreventClose(true); + if (kIsDesktop) { + await windowManager.setPreventClose(true); } await SystemTheme.accentColor.load(); @@ -69,7 +70,7 @@ Future main(List rawArgs) async { MetadataGod.initialize(); } - if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) { + if (kIsWindows || kIsLinux) { DiscordRPC.initialize(); } @@ -101,14 +102,10 @@ Future main(List rawArgs) async { path: hiveCacheDir, ); - await DesktopTools.ensureInitialized( - DesktopWindowOptions( - hideTitleBar: true, - title: "Spotube", - backgroundColor: Colors.transparent, - minimumSize: const Size(300, 700), - ), - ); + if (kIsDesktop) { + await localNotifier.setup(appName: "Spotube"); + await WindowManagerTools.initialize(); + } Catcher2( enableLogger: arguments["verbose"], @@ -135,46 +132,17 @@ Future main(List rawArgs) async { ), runAppFunction: () { runApp( - ProviderScope( - child: DevicePreview( - availableLocales: L10n.all, - enabled: false, - data: const DevicePreviewData( - isEnabled: false, - orientation: Orientation.portrait, - ), - builder: (context) { - return const Spotube(); - }, - ), - ), + const ProviderScope(child: Spotube()), ); }, ); } -class Spotube extends StatefulHookConsumerWidget { +class Spotube extends HookConsumerWidget { const Spotube({super.key}); @override - SpotubeState createState() => SpotubeState(); - - static SpotubeState of(BuildContext context) => - context.findAncestorStateOfType()!; -} - -class SpotubeState extends ConsumerState { - final logger = getLogger(Spotube); - SharedPreferences? localStorage; - - @override - void initState() { - super.initState(); - SharedPreferences.getInstance().then(((value) => localStorage = value)); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, ref) { final themeMode = ref.watch(userPreferencesProvider.select((s) => s.themeMode)); final accentMaterialColor = @@ -189,9 +157,9 @@ class SpotubeState extends ConsumerState { ref.listen(playbackServerProvider, (_, __) {}); ref.listen(connectServerProvider, (_, __) {}); ref.listen(connectClientsProvider, (_, __) {}); + ref.listen(trayManagerProvider, (_, __) {}); useDisableBatteryOptimizations(); - useInitSysTray(ref); useDeepLinking(ref); useCloseBehavior(ref); useGetStoragePermissions(ref); @@ -209,6 +177,7 @@ class SpotubeState extends ConsumerState { () => theme(paletteColor ?? accentMaterialColor, Brightness.light, false), [paletteColor, accentMaterialColor], ); + final darkTheme = useMemoized( () => theme( paletteColor ?? accentMaterialColor, @@ -231,12 +200,8 @@ class SpotubeState extends ConsumerState { debugShowCheckedModeBanner: false, title: 'Spotube', builder: (context, child) { - return DevicePreview.appBuilder( - context, - DesktopTools.platform.isDesktop && !DesktopTools.platform.isMacOS - ? DragToResizeArea(child: child!) - : child, - ); + if (kIsDesktop && !kIsMacOS) return DragToResizeArea(child: child!); + return child!; }, themeMode: themeMode, theme: lightTheme, diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart index efb37315..28386050 100644 --- a/lib/models/connect/connect.dart +++ b/lib/models/connect/connect.dart @@ -5,7 +5,6 @@ import 'dart:convert'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart index dcbd783d..088cfbd1 100644 --- a/lib/models/connect/connect.freezed.dart +++ b/lib/models/connect/connect.freezed.dart @@ -12,20 +12,93 @@ part of 'connect.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); WebSocketLoadEventData _$WebSocketLoadEventDataFromJson( Map json) { - return _WebSocketLoadEventData.fromJson(json); + switch (json['runtimeType']) { + case 'playlist': + return WebSocketLoadEventDataPlaylist.fromJson(json); + case 'album': + return WebSocketLoadEventDataAlbum.fromJson(json); + + default: + throw CheckedFromJsonException( + json, + 'runtimeType', + 'WebSocketLoadEventData', + 'Invalid union type "${json['runtimeType']}"!'); + } } /// @nodoc mixin _$WebSocketLoadEventData { @JsonKey(name: 'tracks', toJson: _tracksJson) List get tracks => throw _privateConstructorUsedError; - String? get collectionId => throw _privateConstructorUsedError; + Object? get collection => throw _privateConstructorUsedError; int? get initialIndex => throw _privateConstructorUsedError; - + @optionalTypeArgs + TResult when({ + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, + required TResult Function(WebSocketLoadEventDataAlbum value) album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult? Function(WebSocketLoadEventDataAlbum value)? album, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult Function(WebSocketLoadEventDataAlbum value)? album, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) $WebSocketLoadEventDataCopyWith get copyWith => @@ -40,7 +113,6 @@ abstract class $WebSocketLoadEventDataCopyWith<$Res> { @useResult $Res call( {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - String? collectionId, int? initialIndex}); } @@ -59,7 +131,6 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, @override $Res call({ Object? tracks = null, - Object? collectionId = freezed, Object? initialIndex = freezed, }) { return _then(_value.copyWith( @@ -67,10 +138,6 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, ? _value.tracks : tracks // ignore: cast_nullable_to_non_nullable as List, - collectionId: freezed == collectionId - ? _value.collectionId - : collectionId // ignore: cast_nullable_to_non_nullable - as String?, initialIndex: freezed == initialIndex ? _value.initialIndex : initialIndex // ignore: cast_nullable_to_non_nullable @@ -80,46 +147,46 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, } /// @nodoc -abstract class _$$WebSocketLoadEventDataImplCopyWith<$Res> +abstract class _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> implements $WebSocketLoadEventDataCopyWith<$Res> { - factory _$$WebSocketLoadEventDataImplCopyWith( - _$WebSocketLoadEventDataImpl value, - $Res Function(_$WebSocketLoadEventDataImpl) then) = - __$$WebSocketLoadEventDataImplCopyWithImpl<$Res>; + factory _$$WebSocketLoadEventDataPlaylistImplCopyWith( + _$WebSocketLoadEventDataPlaylistImpl value, + $Res Function(_$WebSocketLoadEventDataPlaylistImpl) then) = + __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>; @override @useResult $Res call( {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - String? collectionId, + PlaylistSimple? collection, int? initialIndex}); } /// @nodoc -class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> +class __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res> extends _$WebSocketLoadEventDataCopyWithImpl<$Res, - _$WebSocketLoadEventDataImpl> - implements _$$WebSocketLoadEventDataImplCopyWith<$Res> { - __$$WebSocketLoadEventDataImplCopyWithImpl( - _$WebSocketLoadEventDataImpl _value, - $Res Function(_$WebSocketLoadEventDataImpl) _then) + _$WebSocketLoadEventDataPlaylistImpl> + implements _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> { + __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl( + _$WebSocketLoadEventDataPlaylistImpl _value, + $Res Function(_$WebSocketLoadEventDataPlaylistImpl) _then) : super(_value, _then); @pragma('vm:prefer-inline') @override $Res call({ Object? tracks = null, - Object? collectionId = freezed, + Object? collection = freezed, Object? initialIndex = freezed, }) { - return _then(_$WebSocketLoadEventDataImpl( + return _then(_$WebSocketLoadEventDataPlaylistImpl( tracks: null == tracks ? _value._tracks : tracks // ignore: cast_nullable_to_non_nullable as List, - collectionId: freezed == collectionId - ? _value.collectionId - : collectionId // ignore: cast_nullable_to_non_nullable - as String?, + collection: freezed == collection + ? _value.collection + : collection // ignore: cast_nullable_to_non_nullable + as PlaylistSimple?, initialIndex: freezed == initialIndex ? _value.initialIndex : initialIndex // ignore: cast_nullable_to_non_nullable @@ -130,16 +197,21 @@ class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { - _$WebSocketLoadEventDataImpl( +class _$WebSocketLoadEventDataPlaylistImpl + extends WebSocketLoadEventDataPlaylist { + _$WebSocketLoadEventDataPlaylistImpl( {@JsonKey(name: 'tracks', toJson: _tracksJson) required final List tracks, - this.collectionId, - this.initialIndex}) - : _tracks = tracks; + this.collection, + this.initialIndex, + final String? $type}) + : _tracks = tracks, + $type = $type ?? 'playlist', + super._(); - factory _$WebSocketLoadEventDataImpl.fromJson(Map json) => - _$$WebSocketLoadEventDataImplFromJson(json); + factory _$WebSocketLoadEventDataPlaylistImpl.fromJson( + Map json) => + _$$WebSocketLoadEventDataPlaylistImplFromJson(json); final List _tracks; @override @@ -151,23 +223,26 @@ class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { } @override - final String? collectionId; + final PlaylistSimple? collection; @override final int? initialIndex; + @JsonKey(name: 'runtimeType') + final String $type; + @override String toString() { - return 'WebSocketLoadEventData(tracks: $tracks, collectionId: $collectionId, initialIndex: $initialIndex)'; + return 'WebSocketLoadEventData.playlist(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)'; } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$WebSocketLoadEventDataImpl && + other is _$WebSocketLoadEventDataPlaylistImpl && const DeepCollectionEquality().equals(other._tracks, _tracks) && - (identical(other.collectionId, collectionId) || - other.collectionId == collectionId) && + (identical(other.collection, collection) || + other.collection == collection) && (identical(other.initialIndex, initialIndex) || other.initialIndex == initialIndex)); } @@ -175,42 +250,361 @@ class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { @JsonKey(ignore: true) @override int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(_tracks), collectionId, initialIndex); + const DeepCollectionEquality().hash(_tracks), collection, initialIndex); @JsonKey(ignore: true) @override @pragma('vm:prefer-inline') - _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> - get copyWith => __$$WebSocketLoadEventDataImplCopyWithImpl< - _$WebSocketLoadEventDataImpl>(this, _$identity); + _$$WebSocketLoadEventDataPlaylistImplCopyWith< + _$WebSocketLoadEventDataPlaylistImpl> + get copyWith => __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl< + _$WebSocketLoadEventDataPlaylistImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) { + return playlist(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) { + return playlist?.call(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(tracks, collection, initialIndex); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, + required TResult Function(WebSocketLoadEventDataAlbum value) album, + }) { + return playlist(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult? Function(WebSocketLoadEventDataAlbum value)? album, + }) { + return playlist?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult Function(WebSocketLoadEventDataAlbum value)? album, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(this); + } + return orElse(); + } @override Map toJson() { - return _$$WebSocketLoadEventDataImplToJson( + return _$$WebSocketLoadEventDataPlaylistImplToJson( this, ); } } -abstract class _WebSocketLoadEventData implements WebSocketLoadEventData { - factory _WebSocketLoadEventData( +abstract class WebSocketLoadEventDataPlaylist extends WebSocketLoadEventData { + factory WebSocketLoadEventDataPlaylist( {@JsonKey(name: 'tracks', toJson: _tracksJson) required final List tracks, - final String? collectionId, - final int? initialIndex}) = _$WebSocketLoadEventDataImpl; + final PlaylistSimple? collection, + final int? initialIndex}) = _$WebSocketLoadEventDataPlaylistImpl; + WebSocketLoadEventDataPlaylist._() : super._(); - factory _WebSocketLoadEventData.fromJson(Map json) = - _$WebSocketLoadEventDataImpl.fromJson; + factory WebSocketLoadEventDataPlaylist.fromJson(Map json) = + _$WebSocketLoadEventDataPlaylistImpl.fromJson; @override @JsonKey(name: 'tracks', toJson: _tracksJson) List get tracks; @override - String? get collectionId; + PlaylistSimple? get collection; @override int? get initialIndex; @override @JsonKey(ignore: true) - _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + _$$WebSocketLoadEventDataPlaylistImplCopyWith< + _$WebSocketLoadEventDataPlaylistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res> + implements $WebSocketLoadEventDataCopyWith<$Res> { + factory _$$WebSocketLoadEventDataAlbumImplCopyWith( + _$WebSocketLoadEventDataAlbumImpl value, + $Res Function(_$WebSocketLoadEventDataAlbumImpl) then) = + __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex}); +} + +/// @nodoc +class __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res> + extends _$WebSocketLoadEventDataCopyWithImpl<$Res, + _$WebSocketLoadEventDataAlbumImpl> + implements _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res> { + __$$WebSocketLoadEventDataAlbumImplCopyWithImpl( + _$WebSocketLoadEventDataAlbumImpl _value, + $Res Function(_$WebSocketLoadEventDataAlbumImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? tracks = null, + Object? collection = freezed, + Object? initialIndex = freezed, + }) { + return _then(_$WebSocketLoadEventDataAlbumImpl( + tracks: null == tracks + ? _value._tracks + : tracks // ignore: cast_nullable_to_non_nullable + as List, + collection: freezed == collection + ? _value.collection + : collection // ignore: cast_nullable_to_non_nullable + as AlbumSimple?, + initialIndex: freezed == initialIndex + ? _value.initialIndex + : initialIndex // ignore: cast_nullable_to_non_nullable + as int?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum { + _$WebSocketLoadEventDataAlbumImpl( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + this.collection, + this.initialIndex, + final String? $type}) + : _tracks = tracks, + $type = $type ?? 'album', + super._(); + + factory _$WebSocketLoadEventDataAlbumImpl.fromJson( + Map json) => + _$$WebSocketLoadEventDataAlbumImplFromJson(json); + + final List _tracks; + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks { + if (_tracks is EqualUnmodifiableListView) return _tracks; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_tracks); + } + + @override + final AlbumSimple? collection; + @override + final int? initialIndex; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'WebSocketLoadEventData.album(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$WebSocketLoadEventDataAlbumImpl && + const DeepCollectionEquality().equals(other._tracks, _tracks) && + (identical(other.collection, collection) || + other.collection == collection) && + (identical(other.initialIndex, initialIndex) || + other.initialIndex == initialIndex)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, + const DeepCollectionEquality().hash(_tracks), collection, initialIndex); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl> + get copyWith => __$$WebSocketLoadEventDataAlbumImplCopyWithImpl< + _$WebSocketLoadEventDataAlbumImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex) + playlist, + required TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex) + album, + }) { + return album(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult? Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + }) { + return album?.call(tracks, collection, initialIndex); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + PlaylistSimple? collection, + int? initialIndex)? + playlist, + TResult Function( + @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + AlbumSimple? collection, + int? initialIndex)? + album, + required TResult orElse(), + }) { + if (album != null) { + return album(tracks, collection, initialIndex); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, + required TResult Function(WebSocketLoadEventDataAlbum value) album, + }) { + return album(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult? Function(WebSocketLoadEventDataAlbum value)? album, + }) { + return album?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, + TResult Function(WebSocketLoadEventDataAlbum value)? album, + required TResult orElse(), + }) { + if (album != null) { + return album(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$WebSocketLoadEventDataAlbumImplToJson( + this, + ); + } +} + +abstract class WebSocketLoadEventDataAlbum extends WebSocketLoadEventData { + factory WebSocketLoadEventDataAlbum( + {@JsonKey(name: 'tracks', toJson: _tracksJson) + required final List tracks, + final AlbumSimple? collection, + final int? initialIndex}) = _$WebSocketLoadEventDataAlbumImpl; + WebSocketLoadEventDataAlbum._() : super._(); + + factory WebSocketLoadEventDataAlbum.fromJson(Map json) = + _$WebSocketLoadEventDataAlbumImpl.fromJson; + + @override + @JsonKey(name: 'tracks', toJson: _tracksJson) + List get tracks; + @override + AlbumSimple? get collection; + @override + int? get initialIndex; + @override + @JsonKey(ignore: true) + _$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/models/connect/connect.g.dart b/lib/models/connect/connect.g.dart index f636e035..f297024b 100644 --- a/lib/models/connect/connect.g.dart +++ b/lib/models/connect/connect.g.dart @@ -6,20 +6,48 @@ part of 'connect.dart'; // JsonSerializableGenerator // ************************************************************************** -_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson( - Map json) => - _$WebSocketLoadEventDataImpl( - tracks: (json['tracks'] as List) - .map((e) => Track.fromJson(e as Map)) - .toList(), - collectionId: json['collectionId'] as String?, - initialIndex: json['initialIndex'] as int?, - ); +_$WebSocketLoadEventDataPlaylistImpl + _$$WebSocketLoadEventDataPlaylistImplFromJson(Map json) => + _$WebSocketLoadEventDataPlaylistImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(Map.from(e as Map))) + .toList(), + collection: json['collection'] == null + ? null + : PlaylistSimple.fromJson( + Map.from(json['collection'] as Map)), + initialIndex: json['initialIndex'] as int?, + $type: json['runtimeType'] as String?, + ); -Map _$$WebSocketLoadEventDataImplToJson( - _$WebSocketLoadEventDataImpl instance) => +Map _$$WebSocketLoadEventDataPlaylistImplToJson( + _$WebSocketLoadEventDataPlaylistImpl instance) => { 'tracks': _tracksJson(instance.tracks), - 'collectionId': instance.collectionId, + 'collection': instance.collection?.toJson(), 'initialIndex': instance.initialIndex, + 'runtimeType': instance.$type, + }; + +_$WebSocketLoadEventDataAlbumImpl _$$WebSocketLoadEventDataAlbumImplFromJson( + Map json) => + _$WebSocketLoadEventDataAlbumImpl( + tracks: (json['tracks'] as List) + .map((e) => Track.fromJson(Map.from(e as Map))) + .toList(), + collection: json['collection'] == null + ? null + : AlbumSimple.fromJson( + Map.from(json['collection'] as Map)), + initialIndex: json['initialIndex'] as int?, + $type: json['runtimeType'] as String?, + ); + +Map _$$WebSocketLoadEventDataAlbumImplToJson( + _$WebSocketLoadEventDataAlbumImpl instance) => + { + 'tracks': _tracksJson(instance.tracks), + 'collection': instance.collection?.toJson(), + 'initialIndex': instance.initialIndex, + 'runtimeType': instance.$type, }; diff --git a/lib/models/connect/load.dart b/lib/models/connect/load.dart index d750cddd..bf0e164d 100644 --- a/lib/models/connect/load.dart +++ b/lib/models/connect/load.dart @@ -6,14 +6,27 @@ List> _tracksJson(List tracks) { @freezed class WebSocketLoadEventData with _$WebSocketLoadEventData { - factory WebSocketLoadEventData({ + const WebSocketLoadEventData._(); + + factory WebSocketLoadEventData.playlist({ @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, - String? collectionId, + PlaylistSimple? collection, int? initialIndex, - }) = _WebSocketLoadEventData; + }) = WebSocketLoadEventDataPlaylist; + + factory WebSocketLoadEventData.album({ + @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, + AlbumSimple? collection, + int? initialIndex, + }) = WebSocketLoadEventDataAlbum; factory WebSocketLoadEventData.fromJson(Map json) => _$WebSocketLoadEventDataFromJson(json); + + String? get collectionId => when( + playlist: (tracks, collection, _) => collection?.id, + album: (tracks, collection, _) => collection?.id, + ); } class WebSocketLoadEvent extends WebSocketEvent { diff --git a/lib/models/current_playlist.dart b/lib/models/current_playlist.dart index 53ea2799..7e55e393 100644 --- a/lib/models/current_playlist.dart +++ b/lib/models/current_playlist.dart @@ -1,6 +1,5 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; class CurrentPlaylist { diff --git a/lib/models/local_track.dart b/lib/models/local_track.dart index 923f5f26..def3b64f 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -1,5 +1,4 @@ import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; class LocalTrack extends Track { final String path; @@ -35,9 +34,10 @@ class LocalTrack extends Track { ); } + @override Map toJson() { return { - ...TrackExtensions.trackToJson(this), + ...super.toJson(), 'path': path, }; } diff --git a/lib/models/logger.dart b/lib/models/logger.dart index 4f687d09..3236028d 100644 --- a/lib/models/logger.dart +++ b/lib/models/logger.dart @@ -27,7 +27,7 @@ Future getLogsPath() async { } final file = File(path.join(dir, ".spotube_logs")); if (!await file.exists()) { - await file.create(); + await file.create(recursive: true); } return file; } diff --git a/lib/models/source_match.g.dart b/lib/models/source_match.g.dart index 11f34bf3..3b469694 100644 --- a/lib/models/source_match.g.dart +++ b/lib/models/source_match.g.dart @@ -97,7 +97,7 @@ class SourceTypeAdapter extends TypeAdapter { // JsonSerializableGenerator // ************************************************************************** -SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( +SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( id: json['id'] as String, sourceId: json['sourceId'] as String, sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']), diff --git a/lib/models/spotify/home_feed.freezed.dart b/lib/models/spotify/home_feed.freezed.dart index 97c4ffc7..c2bb2aba 100644 --- a/lib/models/spotify/home_feed.freezed.dart +++ b/lib/models/spotify/home_feed.freezed.dart @@ -12,7 +12,7 @@ part of 'home_feed.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); SpotifySectionPlaylist _$SpotifySectionPlaylistFromJson( Map json) { diff --git a/lib/models/spotify/home_feed.g.dart b/lib/models/spotify/home_feed.g.dart index 73a4f909..fceb3db4 100644 --- a/lib/models/spotify/home_feed.g.dart +++ b/lib/models/spotify/home_feed.g.dart @@ -6,14 +6,13 @@ part of 'home_feed.dart'; // JsonSerializableGenerator // ************************************************************************** -_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson( - Map json) => +_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson(Map json) => _$SpotifySectionPlaylistImpl( description: json['description'] as String, format: json['format'] as String, images: (json['images'] as List) - .map((e) => - SpotifySectionItemImage.fromJson(e as Map)) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) .toList(), name: json['name'] as String, owner: json['owner'] as String, @@ -25,20 +24,19 @@ Map _$$SpotifySectionPlaylistImplToJson( { 'description': instance.description, 'format': instance.format, - 'images': instance.images, + 'images': instance.images.map((e) => e.toJson()).toList(), 'name': instance.name, 'owner': instance.owner, 'uri': instance.uri, }; -_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson( - Map json) => +_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson(Map json) => _$SpotifySectionArtistImpl( name: json['name'] as String, uri: json['uri'] as String, images: (json['images'] as List) - .map((e) => - SpotifySectionItemImage.fromJson(e as Map)) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) .toList(), ); @@ -47,19 +45,18 @@ Map _$$SpotifySectionArtistImplToJson( { 'name': instance.name, 'uri': instance.uri, - 'images': instance.images, + 'images': instance.images.map((e) => e.toJson()).toList(), }; -_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson( - Map json) => +_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(Map json) => _$SpotifySectionAlbumImpl( artists: (json['artists'] as List) - .map((e) => - SpotifySectionAlbumArtist.fromJson(e as Map)) + .map((e) => SpotifySectionAlbumArtist.fromJson( + Map.from(e as Map))) .toList(), images: (json['images'] as List) - .map((e) => - SpotifySectionItemImage.fromJson(e as Map)) + .map((e) => SpotifySectionItemImage.fromJson( + Map.from(e as Map))) .toList(), name: json['name'] as String, uri: json['uri'] as String, @@ -68,14 +65,14 @@ _$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson( Map _$$SpotifySectionAlbumImplToJson( _$SpotifySectionAlbumImpl instance) => { - 'artists': instance.artists, - 'images': instance.images, + 'artists': instance.artists.map((e) => e.toJson()).toList(), + 'images': instance.images.map((e) => e.toJson()).toList(), 'name': instance.name, 'uri': instance.uri, }; _$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson( - Map json) => + Map json) => _$SpotifySectionAlbumArtistImpl( name: json['name'] as String, uri: json['uri'] as String, @@ -89,7 +86,7 @@ Map _$$SpotifySectionAlbumArtistImplToJson( }; _$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson( - Map json) => + Map json) => _$SpotifySectionItemImageImpl( height: json['height'] as num?, url: json['url'] as String, @@ -105,40 +102,40 @@ Map _$$SpotifySectionItemImageImplToJson( }; _$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson( - Map json) => + Map json) => _$SpotifyHomeFeedSectionItemImpl( typename: json['typename'] as String, playlist: json['playlist'] == null ? null : SpotifySectionPlaylist.fromJson( - json['playlist'] as Map), + Map.from(json['playlist'] as Map)), artist: json['artist'] == null ? null : SpotifySectionArtist.fromJson( - json['artist'] as Map), + Map.from(json['artist'] as Map)), album: json['album'] == null ? null - : SpotifySectionAlbum.fromJson(json['album'] as Map), + : SpotifySectionAlbum.fromJson( + Map.from(json['album'] as Map)), ); Map _$$SpotifyHomeFeedSectionItemImplToJson( _$SpotifyHomeFeedSectionItemImpl instance) => { 'typename': instance.typename, - 'playlist': instance.playlist, - 'artist': instance.artist, - 'album': instance.album, + 'playlist': instance.playlist?.toJson(), + 'artist': instance.artist?.toJson(), + 'album': instance.album?.toJson(), }; -_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson( - Map json) => +_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson(Map json) => _$SpotifyHomeFeedSectionImpl( typename: json['typename'] as String, title: json['title'] as String?, uri: json['uri'] as String, items: (json['items'] as List) - .map((e) => - SpotifyHomeFeedSectionItem.fromJson(e as Map)) + .map((e) => SpotifyHomeFeedSectionItem.fromJson( + Map.from(e as Map))) .toList(), ); @@ -148,16 +145,15 @@ Map _$$SpotifyHomeFeedSectionImplToJson( 'typename': instance.typename, 'title': instance.title, 'uri': instance.uri, - 'items': instance.items, + 'items': instance.items.map((e) => e.toJson()).toList(), }; -_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson( - Map json) => +_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson(Map json) => _$SpotifyHomeFeedImpl( greeting: json['greeting'] as String, sections: (json['sections'] as List) - .map( - (e) => SpotifyHomeFeedSection.fromJson(e as Map)) + .map((e) => SpotifyHomeFeedSection.fromJson( + Map.from(e as Map))) .toList(), ); @@ -165,5 +161,5 @@ Map _$$SpotifyHomeFeedImplToJson( _$SpotifyHomeFeedImpl instance) => { 'greeting': instance.greeting, - 'sections': instance.sections, + 'sections': instance.sections.map((e) => e.toJson()).toList(), }; diff --git a/lib/models/spotify/recommendation_seeds.freezed.dart b/lib/models/spotify/recommendation_seeds.freezed.dart index 4cfcce12..adf4aab8 100644 --- a/lib/models/spotify/recommendation_seeds.freezed.dart +++ b/lib/models/spotify/recommendation_seeds.freezed.dart @@ -12,7 +12,7 @@ part of 'recommendation_seeds.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); /// @nodoc mixin _$GeneratePlaylistProviderInput { diff --git a/lib/models/spotify/recommendation_seeds.g.dart b/lib/models/spotify/recommendation_seeds.g.dart index bdfa3a07..accb2ed1 100644 --- a/lib/models/spotify/recommendation_seeds.g.dart +++ b/lib/models/spotify/recommendation_seeds.g.dart @@ -6,8 +6,7 @@ part of 'recommendation_seeds.dart'; // JsonSerializableGenerator // ************************************************************************** -_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson( - Map json) => +_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson(Map json) => _$RecommendationSeedsImpl( acousticness: json['acousticness'] as num?, danceability: json['danceability'] as num?, diff --git a/lib/models/spotify_friends.g.dart b/lib/models/spotify_friends.g.dart index 4a32dd09..a1248429 100644 --- a/lib/models/spotify_friends.g.dart +++ b/lib/models/spotify_friends.g.dart @@ -6,60 +6,55 @@ part of 'spotify_friends.dart'; // JsonSerializableGenerator // ************************************************************************** -SpotifyFriend _$SpotifyFriendFromJson(Map json) => - SpotifyFriend( +SpotifyFriend _$SpotifyFriendFromJson(Map json) => SpotifyFriend( uri: json['uri'] as String, name: json['name'] as String, imageUrl: json['imageUrl'] as String, ); -SpotifyActivityArtist _$SpotifyActivityArtistFromJson( - Map json) => +SpotifyActivityArtist _$SpotifyActivityArtistFromJson(Map json) => SpotifyActivityArtist( uri: json['uri'] as String, name: json['name'] as String, ); -SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson( - Map json) => +SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson(Map json) => SpotifyActivityAlbum( uri: json['uri'] as String, name: json['name'] as String, ); -SpotifyActivityContext _$SpotifyActivityContextFromJson( - Map json) => +SpotifyActivityContext _$SpotifyActivityContextFromJson(Map json) => SpotifyActivityContext( uri: json['uri'] as String, name: json['name'] as String, index: json['index'] as num, ); -SpotifyActivityTrack _$SpotifyActivityTrackFromJson( - Map json) => +SpotifyActivityTrack _$SpotifyActivityTrackFromJson(Map json) => SpotifyActivityTrack( uri: json['uri'] as String, name: json['name'] as String, imageUrl: json['imageUrl'] as String, artist: SpotifyActivityArtist.fromJson( - json['artist'] as Map), - album: - SpotifyActivityAlbum.fromJson(json['album'] as Map), + Map.from(json['artist'] as Map)), + album: SpotifyActivityAlbum.fromJson( + Map.from(json['album'] as Map)), context: SpotifyActivityContext.fromJson( - json['context'] as Map), + Map.from(json['context'] as Map)), ); -SpotifyFriendActivity _$SpotifyFriendActivityFromJson( - Map json) => +SpotifyFriendActivity _$SpotifyFriendActivityFromJson(Map json) => SpotifyFriendActivity( - user: SpotifyFriend.fromJson(json['user'] as Map), - track: - SpotifyActivityTrack.fromJson(json['track'] as Map), + user: SpotifyFriend.fromJson( + Map.from(json['user'] as Map)), + track: SpotifyActivityTrack.fromJson( + Map.from(json['track'] as Map)), ); -SpotifyFriends _$SpotifyFriendsFromJson(Map json) => - SpotifyFriends( +SpotifyFriends _$SpotifyFriendsFromJson(Map json) => SpotifyFriends( friends: (json['friends'] as List) - .map((e) => SpotifyFriendActivity.fromJson(e as Map)) + .map((e) => SpotifyFriendActivity.fromJson( + Map.from(e as Map))) .toList(), ); diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index b24b69f4..aea890a0 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -8,6 +8,8 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class AlbumPage extends HookConsumerWidget { + static const name = "album"; + final AlbumSimple album; const AlbumPage({ super.key, @@ -22,7 +24,7 @@ class AlbumPage extends HookConsumerWidget { final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); return InheritedTrackView( - collectionId: album.id!, + collection: album, image: album.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index c3b04691..49890949 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -15,6 +15,8 @@ import 'package:spotube/pages/artist/section/top_tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPage extends HookConsumerWidget { + static const name = "artist"; + final String artistId; final logger = getLogger(ArtistPage); ArtistPage(this.artistId, {super.key}); diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 9d407899..595ac510 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -52,8 +52,9 @@ class ArtistPageTopTracks extends HookConsumerWidget { if (!isPlaylistPlaying) { await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.playlist( tracks: tracks, + collection: null, initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), ), ); diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index cbdb446e..c7cb493a 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -5,10 +5,13 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/connect/local_devices.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/utils/service_utils.dart'; class ConnectPage extends HookConsumerWidget { + static const name = "connect"; + const ConnectPage({super.key}); @override @@ -65,9 +68,9 @@ class ConnectPage extends HookConsumerWidget { selected: selected, onTap: () { if (selected) { - ServiceUtils.push( + ServiceUtils.pushNamed( context, - "/connect/control", + ConnectControlPage.name, ); } else { connectClientsNotifier.resolveService(device); diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index b78f0ed3..639a9dd9 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -13,6 +13,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; @@ -46,6 +47,8 @@ class RemotePlayerQueue extends ConsumerWidget { } class ConnectControlPage extends HookConsumerWidget { + static const name = "connect_control"; + const ConnectControlPage({super.key}); @override @@ -125,9 +128,13 @@ class ConnectControlPage extends HookConsumerWidget { playlist.activeTrack?.name ?? "", style: textTheme.titleLarge!, onTap: () { - ServiceUtils.push( + if (playlist.activeTrack == null) return; + ServiceUtils.pushNamed( context, - "/track/${playlist.activeTrack?.id}", + TrackPage.name, + pathParameters: { + "id": playlist.activeTrack!.id!, + }, ); }, ), diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index 9c061091..c9367e05 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -7,15 +7,17 @@ import 'package:spotube/components/desktop_login/login_form.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/pages/mobile_login/mobile_login.dart'; class DesktopLoginPage extends HookConsumerWidget { + static const name = WebViewLogin.name; const DesktopLoginPage({super.key}); @override Widget build(BuildContext context, ref) { final mediaQuery = MediaQuery.of(context); final theme = Theme.of(context); - final color = theme.colorScheme.surfaceVariant.withOpacity(.3); + final color = theme.colorScheme.surfaceContainerHighest.withOpacity(.3); return SafeArea( child: Scaffold( diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index 83b04af1..dbec28dc 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -8,10 +8,12 @@ import 'package:spotube/components/desktop_login/login_form.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class LoginTutorial extends ConsumerWidget { + static const name = "login_tutorial"; const LoginTutorial({super.key}); @override @@ -53,7 +55,7 @@ class LoginTutorial extends ConsumerWidget { overrideDone: FilledButton( onPressed: authenticationNotifier.isLoggedIn ? () { - ServiceUtils.push(context, "/"); + ServiceUtils.pushNamed(context, HomePage.name); } : null, child: Center(child: Text(context.l10n.done)), diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart index cbab03b9..fa205403 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -12,6 +12,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/themes/theme.dart'; class GettingStarting extends HookConsumerWidget { + static const name = "getting_started"; + const GettingStarting({super.key}); @override diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index 46823425..7bccfe06 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -5,6 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -104,7 +106,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.go("/"); + context.go(HomePage.name); } }, ), @@ -120,7 +122,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.push("/login"); + context.pushNamed(WebViewLogin.name); } }, ), diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart index c945251c..d31b8256 100644 --- a/lib/pages/home/feed/feed_section.dart +++ b/lib/pages/home/feed/feed_section.dart @@ -10,6 +10,8 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/views/home_section.dart'; class HomeFeedSectionPage extends HookConsumerWidget { + static const name = "home_feed_section"; + final String sectionUri; const HomeFeedSectionPage({super.key, required this.sectionUri}); diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index d80b4513..531ea889 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -12,9 +12,11 @@ import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:collection/collection.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; +import 'package:spotube/utils/platform.dart'; class GenrePlaylistsPage extends HookConsumerWidget { + static const name = "genre_playlists"; + final Category category; const GenrePlaylistsPage({super.key, required this.category}); @@ -27,7 +29,7 @@ class GenrePlaylistsPage extends HookConsumerWidget { final scrollController = useScrollController(); return Scaffold( - appBar: DesktopTools.platform.isDesktop + appBar: kIsDesktop ? const PageWindowTitleBar( leading: BackButton(color: Colors.white), backgroundColor: Colors.transparent, @@ -53,12 +55,12 @@ class GenrePlaylistsPage extends HookConsumerWidget { controller: scrollController, slivers: [ SliverAppBar( - automaticallyImplyLeading: DesktopTools.platform.isMobile, + automaticallyImplyLeading: kIsMobile, expandedHeight: mediaQuery.mdAndDown ? 200 : 150, title: const Text(""), backgroundColor: Colors.transparent, flexibleSpace: FlexibleSpaceBar( - centerTitle: DesktopTools.platform.isDesktop, + centerTitle: kIsDesktop, title: Text( category.name!, style: Theme.of(context).textTheme.headlineMedium?.copyWith( diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index 291ce737..bb84fc16 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -9,9 +9,11 @@ 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/pages/home/genres/genre_playlists.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class GenrePage extends HookConsumerWidget { + static const name = "genre"; const GenrePage({super.key}); @override @@ -47,7 +49,13 @@ class GenrePage extends HookConsumerWidget { return InkWell( borderRadius: BorderRadius.circular(8), onTap: () { - context.push("/genre/${category.id}", extra: category); + context.pushNamed( + GenrePlaylistsPage.name, + pathParameters: { + "categoryId": category.id!, + }, + extra: category, + ); }, child: Ink( padding: const EdgeInsets.all(8), diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 31f26bee..d4e2d94e 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/feed.dart'; @@ -10,15 +11,15 @@ import 'package:spotube/components/home/sections/friends.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/image/universal_image.dart'; +import 'package:spotube/components/home/sections/recent.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class HomePage extends HookConsumerWidget { + static const name = "home"; const HomePage({super.key}); @override @@ -33,39 +34,27 @@ class HomePage extends HookConsumerWidget { body: CustomScrollView( controller: controller, slivers: [ - if (mediaQuery.mdAndDown) + if (mediaQuery.smAndDown) SliverAppBar( floating: true, title: Assets.spotubeLogoPng.image(height: 45), actions: [ const ConnectDeviceButton(), const Gap(10), - Consumer(builder: (context, ref, _) { - final me = ref.watch(meProvider); - final meData = me.asData?.value; - - return IconButton( - icon: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - (meData?.images).asUrlString( - placeholder: ImagePlaceholder.artist, - ), - ), - ), - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - ), - onPressed: () { - ServiceUtils.push(context, "/profile"); - }, - ); - }), + IconButton( + icon: const Icon(SpotubeIcons.settings, size: 20), + onPressed: () { + ServiceUtils.pushNamed(context, SettingsPage.name); + }, + ), const Gap(10), ], ) else if (kIsMacOS) const SliverGap(10), const HomeGenresSection(), + const SliverGap(10), + const SliverToBoxAdapter(child: HomeRecentlyPlayedSection()), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), const SliverToBoxAdapter(child: HomeNewReleasesSection()), diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index b6aeef2e..2baeaad9 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -10,6 +10,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; class LastFMLoginPage extends HookConsumerWidget { + static const name = "lastfm_login"; const LastFMLoginPage({super.key}); @override diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index ccdb6a35..5385f872 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -12,6 +12,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class LibraryPage extends HookConsumerWidget { + static const name = "library"; + const LibraryPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -27,7 +29,7 @@ class LibraryPage extends HookConsumerWidget { leading: ThemedButtonsTabBar( tabs: [ Tab(text: " ${context.l10n.playlists} "), - Tab(text: " ${context.l10n.local_tracks} "), + Tab(text: " ${context.l10n.local_tab} "), Tab( child: Badge( isLabelVisible: downloadingCount > 0, diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart new file mode 100644 index 00000000..ac38e860 --- /dev/null +++ b/lib/pages/library/local_folder.dart @@ -0,0 +1,240 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/library/user_local_tracks.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/page_window_title_bar.dart'; +import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class LocalLibraryPage extends HookConsumerWidget { + static const name = "local_library_page"; + + final String location; + final bool isDownloads; + const LocalLibraryPage(this.location, {super.key, this.isDownloads = false}); + + Future playLocalTracks( + WidgetRef ref, + List tracks, { + LocalTrack? currentTrack, + }) async { + final playlist = ref.read(proxyPlaylistProvider); + final playback = ref.read(proxyPlaylistProvider.notifier); + currentTrack ??= tracks.first; + final isPlaylistPlaying = playlist.containsTracks(tracks); + if (!isPlaylistPlaying) { + await playback.load( + tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playback.jumpToTrack(currentTrack); + } + } + + @override + Widget build(BuildContext context, ref) { + final sortBy = useState(SortBy.none); + final playlist = ref.watch(proxyPlaylistProvider); + final trackSnapshot = ref.watch(localTracksProvider); + final isPlaylistPlaying = playlist.containsTracks( + trackSnapshot.asData?.value.values.flattened.toList() ?? []); + + final searchController = useTextEditingController(); + useValueListenable(searchController); + final searchFocus = useFocusNode(); + final isFiltering = useState(false); + + final controller = useScrollController(); + + return SafeArea( + bottom: false, + child: Scaffold( + appBar: PageWindowTitleBar( + leading: const BackButton(), + centerTitle: true, + title: Text(isDownloads ? context.l10n.downloads : location), + backgroundColor: Colors.transparent, + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const SizedBox(width: 5), + FilledButton( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot.asData?.value.isNotEmpty == + true) { + if (!isPlaylistPlaying) { + await playLocalTracks( + ref, + trackSnapshot.asData!.value[location] ?? [], + ); + } + } + } + : null, + child: Row( + children: [ + Text(context.l10n.play), + Icon( + isPlaylistPlaying + ? SpotubeIcons.stop + : SpotubeIcons.play, + ) + ], + ), + ), + const Spacer(), + ExpandableSearchButton( + isFiltering: isFiltering.value, + onPressed: (value) => isFiltering.value = value, + searchFocus: searchFocus, + ), + const SizedBox(width: 10), + SortTracksDropdown( + value: sortBy.value, + onChanged: (value) { + sortBy.value = value; + }, + ), + const SizedBox(width: 5), + FilledButton( + child: const Icon(SpotubeIcons.refresh), + onPressed: () { + ref.invalidate(localTracksProvider); + }, + ) + ], + ), + ), + ExpandableSearchField( + searchController: searchController, + searchFocus: searchFocus, + isFiltering: isFiltering.value, + onChangeFiltering: (value) => isFiltering.value = value, + ), + trackSnapshot.when( + data: (tracks) { + final sortedTracks = useMemoized(() { + return ServiceUtils.sortTracks( + tracks[location] ?? [], sortBy.value); + }, [sortBy.value, tracks]); + + final filteredTracks = useMemoized(() { + if (searchController.text.isEmpty) { + return sortedTracks; + } + return sortedTracks + .map((e) => ( + weightedRatio( + "${e.name} - ${e.artists?.asString() ?? ""}", + searchController.text, + ), + e, + )) + .toList() + .sorted( + (a, b) => b.$1.compareTo(a.$1), + ) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() + .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 { + ref.invalidate(localTracksProvider); + }, + child: InterScrollbar( + controller: controller, + 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( + playlist: playlist, + track: FakeData.track, + index: index, + ); + } + + final track = filteredTracks[index]; + return TrackTile( + index: index, + playlist: playlist, + track: track, + userPlaylist: false, + onTap: () async { + await playLocalTracks( + ref, + sortedTracks, + currentTrack: track, + ); + }, + ); + }, + ), + ), + ), + ), + ); + }, + loading: () => Expanded( + child: Skeletonizer( + enabled: true, + child: ListView.builder( + itemCount: 5, + itemBuilder: (context, index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), + ), + ), + ), + error: (error, stackTrace) => + Text(error.toString() + stackTrace.toString()), + ) + ], + )), + ); + } +} diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 5044090d..648e8528 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -24,6 +24,8 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); class PlaylistGeneratorPage extends HookConsumerWidget { + static const name = "playlist_generator"; + const PlaylistGeneratorPage({super.key}); @override diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 01b73267..5ee7ab36 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -10,10 +10,13 @@ import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistGenerateResultPage extends HookConsumerWidget { + static const name = "playlist_generate_result"; + final GeneratePlaylistProviderInput state; const PlaylistGenerateResultPage({ @@ -123,8 +126,11 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ); if (playlist != null) { - router.go( - '/playlist/${playlist.id}', + router.goNamed( + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, extra: playlist, ); } diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index ca13864a..1d9b383a 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -23,6 +23,8 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class LyricsPage extends HookConsumerWidget { + static const name = "lyrics"; + final bool isModal; const LyricsPage({super.key, this.isModal = false}); @@ -98,7 +100,7 @@ class LyricsPage extends HookConsumerWidget { child: Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background.withOpacity(.4), + color: Theme.of(context).colorScheme.surface.withOpacity(.4), borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 1e4d4641..a026209c 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; @@ -18,8 +17,11 @@ import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.dart'; class MiniLyricsPage extends HookConsumerWidget { + static const name = "mini_lyrics"; + final Size prevSize; const MiniLyricsPage({super.key, required this.prevSize}); @@ -36,9 +38,11 @@ class MiniLyricsPage extends HookConsumerWidget { final showLyrics = useState(true); useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) async { - wasMaximized.value = await DesktopTools.window.isMaximized(); - }); + if (kIsDesktop) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + wasMaximized.value = await windowManager.isMaximized(); + }); + } return null; }, []); @@ -103,8 +107,7 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.lyricsOff), style: ButtonStyle( foregroundColor: showLyrics.value - ? MaterialStateProperty.all( - theme.colorScheme.primary) + ? WidgetStateProperty.all(theme.colorScheme.primary) : null, ), onPressed: () async { @@ -112,11 +115,13 @@ class MiniLyricsPage extends HookConsumerWidget { areaActive.value = true; hoverMode.value = false; - await DesktopTools.window.setSize( - showLyrics.value - ? const Size(400, 500) - : const Size(400, 150), - ); + if (kIsDesktop) { + await windowManager.setSize( + showLyrics.value + ? const Size(400, 500) + : const Size(400, 150), + ); + } }, ), IconButton( @@ -126,8 +131,7 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.hoverOff), style: ButtonStyle( foregroundColor: hoverMode.value - ? MaterialStateProperty.all( - theme.colorScheme.primary) + ? WidgetStateProperty.all(theme.colorScheme.primary) : null, ), onPressed: () async { @@ -135,33 +139,34 @@ class MiniLyricsPage extends HookConsumerWidget { hoverMode.value = !hoverMode.value; }, ), - FutureBuilder( - future: DesktopTools.window.isAlwaysOnTop(), - builder: (context, snapshot) { - return IconButton( - tooltip: context.l10n.always_on_top, - icon: Icon( - snapshot.data == true - ? SpotubeIcons.pinOn - : SpotubeIcons.pinOff, - ), - style: ButtonStyle( - foregroundColor: snapshot.data == true - ? MaterialStateProperty.all( - theme.colorScheme.primary) - : null, - ), - onPressed: snapshot.data == null - ? null - : () async { - await DesktopTools.window.setAlwaysOnTop( - snapshot.data == true ? false : true, - ); - update(); - }, - ); - }, - ), + if (kIsDesktop) + FutureBuilder( + future: windowManager.isAlwaysOnTop(), + builder: (context, snapshot) { + return IconButton( + tooltip: context.l10n.always_on_top, + icon: Icon( + snapshot.data == true + ? SpotubeIcons.pinOn + : SpotubeIcons.pinOff, + ), + style: ButtonStyle( + foregroundColor: snapshot.data == true + ? WidgetStateProperty.all( + theme.colorScheme.primary) + : null, + ), + onPressed: snapshot.data == null + ? null + : () async { + await windowManager.setAlwaysOnTop( + snapshot.data == true ? false : true, + ); + update(); + }, + ); + }, + ), ], ), ), @@ -179,12 +184,12 @@ class MiniLyricsPage extends HookConsumerWidget { child: TabBarView( children: [ SyncedLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), + palette: PaletteColor(theme.colorScheme.surface, 0), isModal: true, defaultTextZoom: 65, ), PlainLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), + palette: PaletteColor(theme.colorScheme.surface, 0), isModal: true, defaultTextZoom: 65, ), @@ -243,19 +248,20 @@ class MiniLyricsPage extends HookConsumerWidget { tooltip: context.l10n.exit_mini_player, icon: const Icon(SpotubeIcons.maximize), onPressed: () async { + if (!kIsDesktop) return; + try { - await DesktopTools.window + await windowManager .setMinimumSize(const Size(300, 700)); - await DesktopTools.window.setAlwaysOnTop(false); + await windowManager.setAlwaysOnTop(false); if (wasMaximized.value) { - await DesktopTools.window.maximize(); + await windowManager.maximize(); } else { - await DesktopTools.window.setSize(prevSize); + await windowManager.setSize(prevSize); } - await DesktopTools.window - .setAlignment(Alignment.center); + await windowManager.setAlignment(Alignment.center); if (!kIsLinux) { - await DesktopTools.window.setHasShadow(true); + await windowManager.setHasShadow(true); } await Future.delayed( const Duration(milliseconds: 200)); diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 0a1ff8b3..1f2df95a 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -7,6 +7,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/platform.dart'; class WebViewLogin extends HookConsumerWidget { + static const name = "login"; const WebViewLogin({super.key}); @override diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 72983518..44e99aea 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -3,9 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class LikedPlaylistPage extends HookConsumerWidget { + static const name = PlaylistPage.name; + final PlaylistSimple playlist; const LikedPlaylistPage({ super.key, @@ -18,7 +21,7 @@ class LikedPlaylistPage extends HookConsumerWidget { final tracks = likedTracks.asData?.value ?? []; return InheritedTrackView( - collectionId: playlist.id!, + collection: playlist, image: "assets/liked-tracks.jpg", pagination: PaginationProps( hasNextPage: false, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index d9d224e0..8fb22458 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -10,6 +10,8 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistPage extends HookConsumerWidget { + static const name = "playlist"; + final PlaylistSimple playlist; const PlaylistPage({ super.key, @@ -29,7 +31,7 @@ class PlaylistPage extends HookConsumerWidget { final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); return InheritedTrackView( - collectionId: playlist.id!, + collection: playlist, image: playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart index 52b69835..d77ae98d 100644 --- a/lib/pages/profile/profile.dart +++ b/lib/pages/profile/profile.dart @@ -14,6 +14,8 @@ import 'package:spotube/provider/spotify/spotify.dart'; import 'package:url_launcher/url_launcher_string.dart'; class ProfilePage extends HookConsumerWidget { + static const name = "profile"; + const ProfilePage({super.key}); @override diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 56ea43a6..258ecf3c 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -15,19 +14,14 @@ import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; -import 'package:spotube/hooks/configurators/use_update_checker.dart'; +import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; - -const rootPaths = { - "/": 0, - "/search": 1, - "/library": 2, - "/lyrics": 3, -}; +import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/service_utils.dart'; class RootApp extends HookConsumerWidget { final Widget child; @@ -38,15 +32,15 @@ class RootApp extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final isMounted = useIsMounted(); final showingDialogCompleter = useRef(Completer()..complete()); final downloader = ref.watch(downloadManagerProvider); final scaffoldMessenger = ScaffoldMessenger.of(context); final theme = Theme.of(context); - final location = GoRouterState.of(context).matchedLocation; useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { + ServiceUtils.checkForUpdates(context, ref); + final sharedPreferences = await SharedPreferences.getInstance(); if (sharedPreferences.getBool(kIsUsingEncryption) == false && @@ -129,7 +123,7 @@ class RootApp extends HookConsumerWidget { useEffect(() { downloader.onFileExists = (track) async { - if (!isMounted()) return false; + if (!context.mounted) return false; if (!showingDialogCompleter.value.isCompleted) { await showingDialogCompleter.value.future; @@ -161,7 +155,6 @@ class RootApp extends HookConsumerWidget { }, [downloader]); // checks for latest version of the application - useUpdateChecker(ref); useEndlessPlayback(ref); @@ -179,35 +172,21 @@ class RootApp extends HookConsumerWidget { return null; }, [backgroundColor]); - void onSelectIndexChanged(int d) { - final invertedRouteMap = - rootPaths.map((key, value) => MapEntry(value, key)); - - if (context.mounted) { - WidgetsBinding.instance.addPostFrameCallback((_) { - GoRouter.of(context).go(invertedRouteMap[d]!); - }); - } - } - // ignore: deprecated_member_use return WillPopScope( onWillPop: () async { - if (rootPaths[location] != 0) { - onSelectIndexChanged(0); + final routerState = GoRouterState.of(context); + if (routerState.matchedLocation != "/") { + context.goNamed(HomePage.name); return false; } return true; }, child: Scaffold( - body: Sidebar( - selectedIndex: rootPaths[location], - onSelectedIndexChanged: onSelectIndexChanged, - child: child, - ), + body: Sidebar(child: child), extendBody: true, drawerScrimColor: Colors.transparent, - endDrawer: DesktopTools.platform.isDesktop + endDrawer: kIsDesktop ? Container( constraints: const BoxConstraints(maxWidth: 800), decoration: BoxDecoration( @@ -238,10 +217,7 @@ class RootApp extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ BottomPlayer(), - SpotubeNavigationBar( - selectedIndex: rootPaths[location], - onSelectedIndexChanged: onSelectIndexChanged, - ), + const SpotubeNavigationBar(), ], ), ), diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index e9ada236..50ef152b 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -26,6 +27,8 @@ import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; class SearchPage extends HookConsumerWidget { + static const name = "search"; + const SearchPage({super.key}); @override @@ -85,99 +88,117 @@ class SearchPage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: kIsDesktop && !kIsMacOS ? const PageWindowTitleBar() : null, + appBar: kIsDesktop && !kIsMacOS + ? const PageWindowTitleBar(automaticallyImplyLeading: true) + : null, body: !authenticationNotifier.isLoggedIn ? const AnonymousFallback() : Column( children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ), - color: theme.scaffoldBackgroundColor, - child: SearchAnchor( - searchController: controller, - viewBuilder: (_) => HookBuilder(builder: (context) { - final searchController = useListenable(controller); - final update = useForceUpdate(); - final suggestions = searchController.text.isEmpty - ? KVStoreService.recentSearches - : KVStoreService.recentSearches - .where( - (s) => - weightedRatio( - s.toLowerCase(), - searchController.text.toLowerCase(), - ) > - 50, - ) - .toList(); + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if ((kIsMobile || kIsMacOS) && context.canPop()) + const BackButton() + else + const Gap(20), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + right: 20, + top: 20, + bottom: 20, + ), + child: SearchAnchor( + searchController: controller, + viewBuilder: (_) => HookBuilder(builder: (context) { + final searchController = + useListenable(controller); + final update = useForceUpdate(); + final suggestions = searchController.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + searchController.text + .toLowerCase(), + ) > + 50, + ) + .toList(); - return ListView.builder( - itemCount: suggestions.length, - itemBuilder: (context, index) { - final suggestion = suggestions[index]; + return ListView.builder( + itemCount: suggestions.length, + itemBuilder: (context, index) { + final suggestion = suggestions[index]; - return ListTile( - leading: const Icon(SpotubeIcons.history), - title: Text(suggestion), - trailing: IconButton( - icon: const Icon(SpotubeIcons.trash), - onPressed: () { - KVStoreService.setRecentSearches( - KVStoreService.recentSearches - .where((s) => s != suggestion) - .toList(), + return ListTile( + leading: const Icon(SpotubeIcons.history), + title: Text(suggestion), + trailing: IconButton( + icon: const Icon(SpotubeIcons.trash), + onPressed: () { + KVStoreService.setRecentSearches( + KVStoreService.recentSearches + .where((s) => s != suggestion) + .toList(), + ); + update(); + }, + ), + onTap: () { + controller.closeView(suggestion); + ref + .read( + searchTermStateProvider.notifier) + .state = suggestion; + }, ); - update(); }, - ), - onTap: () { - controller.closeView(suggestion); - ref - .read(searchTermStateProvider.notifier) - .state = suggestion; - }, - ); - }, - ); - }), - suggestionsBuilder: (context, controller) { - return []; - }, - viewOnSubmitted: (value) async { - controller.closeView(value); - Timer( - const Duration(milliseconds: 50), - () { - ref.read(searchTermStateProvider.notifier).state = - value; - if (value.trim().isEmpty) { - return; - } - KVStoreService.setRecentSearches( - { - value, - ...KVStoreService.recentSearches, - }.toList(), - ); - }, - ); - }, - builder: (context, controller) { - return SearchBar( - autoFocus: queries.none((s) => - s.asData?.value != null && !s.hasError) && - !kIsMobile, - controller: controller, - leading: const Icon(SpotubeIcons.search), - hintText: "${context.l10n.search}...", - onTap: controller.openView, - onChanged: (_) => controller.openView(), - ); - }, - ), + ); + }), + suggestionsBuilder: (context, controller) { + return []; + }, + viewOnSubmitted: (value) async { + controller.closeView(value); + Timer( + const Duration(milliseconds: 50), + () { + ref + .read(searchTermStateProvider.notifier) + .state = value; + if (value.trim().isEmpty) { + return; + } + KVStoreService.setRecentSearches( + { + value, + ...KVStoreService.recentSearches, + }.toList(), + ); + }, + ); + }, + builder: (context, controller) { + return SearchBar( + autoFocus: queries.none((s) => + s.asData?.value != null && + !s.hasError) && + !kIsMobile, + controller: controller, + leading: const Icon(SpotubeIcons.search), + hintText: "${context.l10n.search}...", + onTap: controller.openView, + onChanged: (_) => controller.openView(), + ); + }, + ), + ), + ), + ], ), Expanded( child: AnimatedSwitcher( @@ -191,7 +212,7 @@ class SearchPage extends HookConsumerWidget { Icon( SpotubeIcons.web, size: 120, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.7), ), const SizedBox(height: 20), @@ -199,7 +220,7 @@ class SearchPage extends HookConsumerWidget { context.l10n.search_to_get_results, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w900, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.5), ), ), @@ -225,7 +246,7 @@ class SearchPage extends HookConsumerWidget { style: TextStyle( fontSize: 20, fontWeight: FontWeight.w900, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.7), ), ), diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 48dabc13..bd7f3c88 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -76,7 +76,7 @@ class SearchTracksSection extends HookConsumerWidget { if (shouldPlay) { await remotePlayback.load( - WebSocketLoadEventData( + WebSocketLoadEventData.playlist( tracks: [track], ), ); @@ -113,7 +113,7 @@ class SearchTracksSection extends HookConsumerWidget { child: TextButton( onPressed: searchTrack.isLoadingNextPage ? null - : () => searchTrackNotifier.fetchMore, + : searchTrackNotifier.fetchMore, child: searchTrack.isLoadingNextPage ? const CircularProgressIndicator() : Text(context.l10n.load_more), diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 21b8117b..e7d95759 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/env.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; @@ -16,6 +17,8 @@ final _licenseProvider = FutureProvider((ref) async { }); class AboutSpotube extends HookConsumerWidget { + static const name = "about"; + const AboutSpotube({super.key}); @override @@ -72,6 +75,13 @@ class AboutSpotube extends HookConsumerWidget { Text("v${packageInfo.version}") ], ), + TableRow( + children: [ + Text(context.l10n.channel), + colon, + Text(Env.releaseChannel.name) + ], + ), TableRow( children: [ Text(context.l10n.build_number), diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 9dd85c50..4e937922 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -11,6 +11,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; class BlackListPage extends HookConsumerWidget { + static const name = "blacklist"; + const BlackListPage({super.key}); @override @@ -18,7 +20,6 @@ class BlackListPage extends HookConsumerWidget { final controller = useScrollController(); final blacklist = ref.watch(blacklistProvider); final searchText = useState(""); - final filteredBlacklist = useMemoized( () { if (searchText.value.isEmpty) { diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index b07ebbb1..8b6f7312 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -11,6 +11,8 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; class LogsPage extends HookWidget { + static const name = "logs"; + const LogsPage({super.key}); List<({DateTime? date, String body})> parseLogs(String raw) { diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index a8d72cc0..5e5d2377 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -43,10 +43,9 @@ class SettingsAboutSection extends HookConsumerWidget { ), trailing: (context, update) => FilledButton( style: ButtonStyle( - backgroundColor: MaterialStatePropertyAll(Colors.red[100]), - foregroundColor: - const MaterialStatePropertyAll(Colors.pinkAccent), - padding: const MaterialStatePropertyAll(EdgeInsets.all(15)), + backgroundColor: WidgetStatePropertyAll(Colors.red[100]), + foregroundColor: const WidgetStatePropertyAll(Colors.pinkAccent), + padding: const WidgetStatePropertyAll(EdgeInsets.all(15)), ), onPressed: () { launchUrlString( diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index ab3a7c92..5acab480 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -4,10 +4,15 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/utils/service_utils.dart'; class SettingsAccountSection extends HookConsumerWidget { const SettingsAccountSection({super.key}); @@ -15,9 +20,12 @@ class SettingsAccountSection extends HookConsumerWidget { @override Widget build(context, ref) { final theme = Theme.of(context); + final router = GoRouter.of(context); + final auth = ref.watch(authenticationProvider); final scrobbler = ref.watch(scrobblerProvider); - final router = GoRouter.of(context); + final me = ref.watch(meProvider); + final meData = me.asData?.value; final logoutBtnStyle = FilledButton.styleFrom( backgroundColor: Colors.red, @@ -27,6 +35,24 @@ class SettingsAccountSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.account, children: [ + if (auth != null) + ListTile( + leading: const Icon(SpotubeIcons.user), + title: const Text("User Profile"), + trailing: Padding( + padding: const EdgeInsets.all(8.0), + child: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (meData?.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + ), + onTap: () { + ServiceUtils.pushNamed(context, ProfilePage.name); + }, + ), if (auth == null) LayoutBuilder(builder: (context, constrains) { return ListTile( @@ -56,7 +82,7 @@ class SettingsAccountSection extends HookConsumerWidget { router.push("/login"); }, style: ButtonStyle( - shape: MaterialStateProperty.all( + shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(25.0), ), diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index 4e4408d9..56306868 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -8,6 +7,7 @@ import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/utils/platform.dart'; class SettingsDesktopSection extends HookConsumerWidget { const SettingsDesktopSection({super.key}); @@ -53,7 +53,7 @@ class SettingsDesktopSection extends HookConsumerWidget { value: preferences.systemTitleBar, onChanged: preferencesNotifier.setSystemTitleBar, ), - if (!DesktopTools.platform.isMacOS) + if (!kIsMacOS) SwitchListTile( secondary: const Icon(SpotubeIcons.discord), title: Text(context.l10n.discord_rich_presence), diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index 1f25028e..76ef8e3e 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -1,13 +1,13 @@ import 'package:file_picker/file_picker.dart'; import 'package:file_selector/file_selector.dart'; 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/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; class SettingsDownloadsSection extends HookConsumerWidget { const SettingsDownloadsSection({super.key}); @@ -18,7 +18,7 @@ class SettingsDownloadsSection extends HookConsumerWidget { final preferences = ref.watch(userPreferencesProvider); final pickDownloadLocation = useCallback(() async { - if (DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS) { + if (kIsMobile || kIsMacOS) { final dirStr = await FilePicker.platform.getDirectoryPath( initialDirectory: preferences.downloadLocation, ); diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index d2a75057..af0fc095 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,6 +1,5 @@ import 'package:flutter/foundation.dart'; 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/shared/page_window_title_bar.dart'; @@ -14,8 +13,11 @@ import 'package:spotube/pages/settings/sections/downloads.dart'; import 'package:spotube/pages/settings/sections/language_region.dart'; import 'package:spotube/pages/settings/sections/playback.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; class SettingsPage extends HookConsumerWidget { + static const name = "settings"; + const SettingsPage({super.key}); @override @@ -29,6 +31,7 @@ class SettingsPage extends HookConsumerWidget { appBar: PageWindowTitleBar( title: Text(context.l10n.settings), centerTitle: true, + automaticallyImplyLeading: true, ), body: Scrollbar( controller: controller, @@ -45,8 +48,7 @@ class SettingsPage extends HookConsumerWidget { const SettingsAppearanceSection(), const SettingsPlaybackSection(), const SettingsDownloadsSection(), - if (DesktopTools.platform.isDesktop) - const SettingsDesktopSection(), + if (kIsDesktop) const SettingsDesktopSection(), if (!kIsWeb) const SettingsDevelopersSection(), const SettingsAboutSection(), Center( diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart new file mode 100644 index 00000000..83867f93 --- /dev/null +++ b/lib/pages/stats/albums/albums.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/album_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsAlbumsPage extends HookConsumerWidget { + static const name = "stats_albums"; + const StatsAlbumsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final albums = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.albums), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Albums"), + ), + body: ListView.builder( + itemCount: albums.length, + itemBuilder: (context, index) { + final album = albums[index]; + return StatsAlbumItem( + album: album.album, + info: Text("${compactNumberFormatter.format(album.count)} plays"), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart new file mode 100644 index 00000000..755475ae --- /dev/null +++ b/lib/pages/stats/artists/artists.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsArtistsPage extends HookConsumerWidget { + static const name = "stats_artists"; + const StatsArtistsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final artists = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.artists), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Artists"), + ), + body: ListView.builder( + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text("${compactNumberFormatter.format(artist.count)} plays"), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart new file mode 100644 index 00000000..228d3243 --- /dev/null +++ b/lib/pages/stats/fees/fees.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsStreamFeesPage extends HookConsumerWidget { + static const name = "stats_stream_fees"; + + const StatsStreamFeesPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :hintColor) = Theme.of(context); + + final artists = ref.watch( + playbackHistoryTopProvider(HistoryDuration.days30) + .select((value) => value.artists), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Streaming fees (hypothetical)"), + ), + body: CustomScrollView( + slivers: [ + SliverCrossAxisConstrained( + maxCrossAxisExtent: 600, + alignment: -1, + child: SliverPadding( + padding: const EdgeInsets.all(16.0), + sliver: SliverToBoxAdapter( + child: Text( + "*This is calculated based on Spotify's per stream " + "payout of \$0.003 to \$0.005. This is a hypothetical " + "calculation to give user insight about how much they " + "would have paid to the artists if they were to listen " + "their song in Spotify.", + style: textTheme.bodySmall?.copyWith( + color: hintColor, + ), + ), + ), + ), + ), + SliverList.builder( + itemCount: artists.length, + itemBuilder: (context, index) { + final artist = artists[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text(usdFormatter.format(artist.count * 0.005)), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart new file mode 100644 index 00000000..b22f9a4f --- /dev/null +++ b/lib/pages/stats/minutes/minutes.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsMinutesPage extends HookConsumerWidget { + static const name = "stats_minutes"; + + const StatsMinutesPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final topTracks = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.tracks), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + title: Text("Minutes listened"), + centerTitle: false, + automaticallyImplyLeading: true, + ), + body: ListView.separated( + separatorBuilder: (context, index) => const Gap(8), + itemCount: topTracks.length, + itemBuilder: (context, index) { + final (:track, :count) = topTracks[index]; + + return StatsTrackItem( + track: track, + info: Text( + "${compactNumberFormatter.format(count * track.duration!.inMinutes)} mins", + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart new file mode 100644 index 00000000..cca7febb --- /dev/null +++ b/lib/pages/stats/playlists/playlists.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/playlist_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsPlaylistsPage extends HookConsumerWidget { + static const name = "stats_playlists"; + const StatsPlaylistsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final playlists = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.playlists), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + centerTitle: false, + title: Text("Playlists"), + ), + body: ListView.builder( + itemCount: playlists.length, + itemBuilder: (context, index) { + final playlist = playlists[index]; + return StatsPlaylistItem( + playlist: playlist.playlist.playlist, + info: + Text("${compactNumberFormatter.format(playlist.count)} plays"), + ); + }, + ), + ); + } +} diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart new file mode 100644 index 00000000..95493591 --- /dev/null +++ b/lib/pages/stats/stats.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/summary/summary.dart'; +import 'package:spotube/components/stats/top/top.dart'; +import 'package:spotube/utils/platform.dart'; + +class StatsPage extends HookConsumerWidget { + static const name = "stats"; + + const StatsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + return SafeArea( + bottom: false, + child: Scaffold( + appBar: kIsMacOS || kIsMobile ? null : const PageWindowTitleBar(), + body: CustomScrollView( + slivers: [ + if (kIsMacOS) const SliverGap(20), + const StatsPageSummarySection(), + const StatsPageTopSection(), + const SliverToBoxAdapter( + child: SafeArea( + child: SizedBox(), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart new file mode 100644 index 00000000..33480709 --- /dev/null +++ b/lib/pages/stats/streams/streams.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/formatters.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +class StatsStreamsPage extends HookConsumerWidget { + static const name = "stats_streams"; + + const StatsStreamsPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final topTracks = ref.watch( + playbackHistoryTopProvider(HistoryDuration.allTime) + .select((s) => s.tracks), + ); + + return Scaffold( + appBar: const PageWindowTitleBar( + title: Text("Streamed songs"), + centerTitle: false, + automaticallyImplyLeading: true, + ), + body: ListView.separated( + separatorBuilder: (context, index) => const Gap(8), + itemCount: topTracks.length, + itemBuilder: (context, index) { + final (:track, :count) = topTracks[index]; + + return StatsTrackItem( + track: track, + info: Text( + "${compactNumberFormatter.format(count)} streams", + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index fc90d19a..2109fe6e 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -21,6 +21,8 @@ import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/extensions/constrains.dart'; class TrackPage extends HookConsumerWidget { + static const name = "track"; + final String trackId; const TrackPage({ super.key, diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index a82f82c0..be61cb4f 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -1,10 +1,12 @@ import 'dart:async'; -import 'dart:convert'; +import 'dart:io'; import 'package:collection/collection.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart' + hide X509Certificate; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/extensions/context.dart'; @@ -18,6 +20,18 @@ class AuthenticationCredentials { bool get isExpired => DateTime.now().isAfter(expiration); + static final Dio dio = () { + final dio = Dio(); + + (dio.httpClientAdapter as IOHttpClientAdapter) + .createHttpClient = () => HttpClient() + ..badCertificateCallback = (X509Certificate cert, String host, int port) { + return host.endsWith("spotify.com") && port == 443; + }; + + return dio; + }(); + AuthenticationCredentials({ required this.cookie, required this.accessToken, @@ -30,26 +44,29 @@ class AuthenticationCredentials { .split("; ") .firstWhereOrNull((c) => c.trim().startsWith("sp_dc=")) ?.trim(); - final res = await get( + final res = await dio.getUri( Uri.parse( "https://open.spotify.com/get_access_token?reason=transport&productType=web_player", ), - headers: { - "Cookie": spDc ?? "", - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" - }, + options: Options( + headers: { + "Cookie": spDc ?? "", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + }, + validateStatus: (status) => true, + ), ); - final body = jsonDecode(res.body); + final body = res.data; - if (res.statusCode >= 400) { + if ((res.statusCode ?? 500) >= 400) { throw Exception( - "Failed to get access token: ${body['error'] ?? res.reasonPhrase}", + "Failed to get access token: ${body['error'] ?? res.statusMessage}", ); } return AuthenticationCredentials( - cookie: "${res.headers["set-cookie"]}; $spDc", + cookie: "${res.headers["set-cookie"]?.join(";")}; $spDc", accessToken: body['accessToken'], expiration: DateTime.fromMillisecondsSinceEpoch( body['accessTokenExpirationTimestampMs'], diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart index ebf53e43..9c4e6466 100644 --- a/lib/provider/connect/server.dart +++ b/lib/provider/connect/server.dart @@ -9,9 +9,11 @@ import 'package:shelf/shelf_io.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -32,6 +34,7 @@ final connectServerProvider = FutureProvider((ref) async { final resolvedService = await ref .watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); final playbackNotifier = ref.read(proxyPlaylistProvider.notifier); + final historyNotifier = ref.read(playbackHistoryProvider.notifier); if (!enabled || resolvedService != null) { return null; @@ -79,7 +82,7 @@ final connectServerProvider = FutureProvider((ref) async { .toJson(), ); channel.sink.add( - WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(), + WebSocketShuffleEvent(audioPlayer.isShuffled).toJson(), ); channel.sink.add( WebSocketLoopEvent(audioPlayer.loopMode).toJson(), @@ -146,8 +149,14 @@ final connectServerProvider = FutureProvider((ref) async { initialIndex: event.data.initialIndex ?? 0, ); - if (event.data.collectionId != null) { - playbackNotifier.addCollection(event.data.collectionId!); + if (event.data.collectionId == null) return; + playbackNotifier.addCollection(event.data.collectionId!); + if (event.data.collection is AlbumSimple) { + historyNotifier + .addAlbums([event.data.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists( + [event.data.collection as PlaylistSimple]); } }); diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index ca8eecfa..f90db54a 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -1,21 +1,19 @@ 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/extensions/artist_simple.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/platform.dart'; class Discord extends ChangeNotifier { final DiscordRPC? discordRPC; final bool isEnabled; Discord(this.isEnabled) - : discordRPC = (DesktopTools.platform.isWindows || - DesktopTools.platform.isLinux) && - isEnabled + : discordRPC = (kIsWindows || kIsLinux) && isEnabled ? DiscordRPC(applicationId: Env.discordAppId) : null { discordRPC?.start(autoRegister: true); diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart new file mode 100644 index 00000000..4436626d --- /dev/null +++ b/lib/provider/history/history.dart @@ -0,0 +1,129 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/utils/persisted_state_notifier.dart'; + +class PlaybackHistoryState { + final List items; + const PlaybackHistoryState({this.items = const []}); + + factory PlaybackHistoryState.fromJson(Map json) { + return PlaybackHistoryState( + items: json["items"] + ?.map( + (json) => PlaybackHistoryItem.fromJson(json), + ) + .toList() + .cast() ?? + [], + ); + } + + Map toJson() { + return { + "items": items.map((s) => s.toJson()).toList(), + }; + } + + PlaybackHistoryState copyWith({ + List? items, + }) { + return PlaybackHistoryState(items: items ?? this.items); + } +} + +class PlaybackHistoryNotifier + extends PersistedStateNotifier { + final Ref ref; + PlaybackHistoryNotifier(this.ref) + : super(const PlaybackHistoryState(), "playback_history"); + + SpotifyApi get spotify => ref.read(spotifyProvider); + + @override + FutureOr fromJson(Map json) => + PlaybackHistoryState.fromJson(json); + + @override + Map toJson() { + return state.toJson(); + } + + void addPlaylists(List playlists) { + state = state.copyWith( + items: [ + ...state.items, + for (final playlist in playlists) + PlaybackHistoryItem.playlist( + date: DateTime.now(), playlist: playlist), + ], + ); + } + + void addAlbums(List albums) { + state = state.copyWith( + items: [ + ...state.items, + for (final album in albums) + PlaybackHistoryItem.album(date: DateTime.now(), album: album), + ], + ); + } + + void addTrack(Track track) async { + // For some reason Track's artists images are `null` + // so we need to fetch them from the API + final artists = + await spotify.artists.list(track.artists!.map((e) => e.id!).toList()); + + track.artists = artists.toList(); + + state = state.copyWith( + items: [ + ...state.items, + PlaybackHistoryItem.track(date: DateTime.now(), track: track), + ], + ); + } + + void clear() { + state = state.copyWith(items: []); + } +} + +final playbackHistoryProvider = + StateNotifierProvider( + (ref) => PlaybackHistoryNotifier(ref), +); + +typedef PlaybackHistoryGrouped = ({ + List tracks, + List albums, + List playlists, +}); + +final playbackHistoryGroupedProvider = Provider((ref) { + final history = ref.watch(playbackHistoryProvider); + final tracks = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + final albums = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + final playlists = history.items + .whereType() + .sorted((a, b) => b.date.compareTo(a.date)) + .toList(); + + return ( + tracks: tracks, + albums: albums, + playlists: playlists, + ); +}); diff --git a/lib/provider/history/recent.dart b/lib/provider/history/recent.dart new file mode 100644 index 00000000..9953858d --- /dev/null +++ b/lib/provider/history/recent.dart @@ -0,0 +1,40 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/history/state.dart'; + +final recentlyPlayedItems = Provider((ref) { + return ref.watch( + playbackHistoryProvider.select( + (s) => s.items + .toSet() + // unique items + .whereIndexed( + (index, item) => + index == + s.items.lastIndexWhere( + (e) => switch ((e, item)) { + ( + PlaybackHistoryPlaylist(:final playlist), + PlaybackHistoryPlaylist(playlist: final playlist2) + ) => + playlist.id == playlist2.id, + ( + PlaybackHistoryAlbum(:final album), + PlaybackHistoryAlbum(album: final album2) + ) => + album.id == album2.id, + _ => false, + }, + ), + ) + .where( + (s) => s is PlaybackHistoryPlaylist || s is PlaybackHistoryAlbum, + ) + .take(10) + .sortedBy((s) => s.date) + .reversed + .toList(), + ), + ); +}); diff --git a/lib/provider/history/state.dart b/lib/provider/history/state.dart new file mode 100644 index 00000000..67658502 --- /dev/null +++ b/lib/provider/history/state.dart @@ -0,0 +1,35 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; + +part 'state.freezed.dart'; +part 'state.g.dart'; + +enum HistoryDuration { + allTime, + days7, + days30, + months6, + year, + years2, +} + +@freezed +class PlaybackHistoryItem with _$PlaybackHistoryItem { + factory PlaybackHistoryItem.playlist({ + required DateTime date, + required PlaylistSimple playlist, + }) = PlaybackHistoryPlaylist; + + factory PlaybackHistoryItem.album({ + required DateTime date, + required AlbumSimple album, + }) = PlaybackHistoryAlbum; + + factory PlaybackHistoryItem.track({ + required DateTime date, + required Track track, + }) = PlaybackHistoryTrack; + + factory PlaybackHistoryItem.fromJson(Map json) => + _$PlaybackHistoryItemFromJson(json); +} diff --git a/lib/provider/history/state.freezed.dart b/lib/provider/history/state.freezed.dart new file mode 100644 index 00000000..e2ee9421 --- /dev/null +++ b/lib/provider/history/state.freezed.dart @@ -0,0 +1,644 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +PlaybackHistoryItem _$PlaybackHistoryItemFromJson(Map json) { + switch (json['runtimeType']) { + case 'playlist': + return PlaybackHistoryPlaylist.fromJson(json); + case 'album': + return PlaybackHistoryAlbum.fromJson(json); + case 'track': + return PlaybackHistoryTrack.fromJson(json); + + default: + throw CheckedFromJsonException(json, 'runtimeType', 'PlaybackHistoryItem', + 'Invalid union type "${json['runtimeType']}"!'); + } +} + +/// @nodoc +mixin _$PlaybackHistoryItem { + DateTime get date => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $PlaybackHistoryItemCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PlaybackHistoryItemCopyWith<$Res> { + factory $PlaybackHistoryItemCopyWith( + PlaybackHistoryItem value, $Res Function(PlaybackHistoryItem) then) = + _$PlaybackHistoryItemCopyWithImpl<$Res, PlaybackHistoryItem>; + @useResult + $Res call({DateTime date}); +} + +/// @nodoc +class _$PlaybackHistoryItemCopyWithImpl<$Res, $Val extends PlaybackHistoryItem> + implements $PlaybackHistoryItemCopyWith<$Res> { + _$PlaybackHistoryItemCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + }) { + return _then(_value.copyWith( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$PlaybackHistoryPlaylistImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryPlaylistImplCopyWith( + _$PlaybackHistoryPlaylistImpl value, + $Res Function(_$PlaybackHistoryPlaylistImpl) then) = + __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, PlaylistSimple playlist}); +} + +/// @nodoc +class __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, + _$PlaybackHistoryPlaylistImpl> + implements _$$PlaybackHistoryPlaylistImplCopyWith<$Res> { + __$$PlaybackHistoryPlaylistImplCopyWithImpl( + _$PlaybackHistoryPlaylistImpl _value, + $Res Function(_$PlaybackHistoryPlaylistImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? playlist = null, + }) { + return _then(_$PlaybackHistoryPlaylistImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + playlist: null == playlist + ? _value.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as PlaylistSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist { + _$PlaybackHistoryPlaylistImpl( + {required this.date, required this.playlist, final String? $type}) + : $type = $type ?? 'playlist'; + + factory _$PlaybackHistoryPlaylistImpl.fromJson(Map json) => + _$$PlaybackHistoryPlaylistImplFromJson(json); + + @override + final DateTime date; + @override + final PlaylistSimple playlist; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.playlist(date: $date, playlist: $playlist)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryPlaylistImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.playlist, playlist) || + other.playlist == playlist)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, playlist); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> + get copyWith => __$$PlaybackHistoryPlaylistImplCopyWithImpl< + _$PlaybackHistoryPlaylistImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return playlist(date, this.playlist); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return playlist?.call(date, this.playlist); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(date, this.playlist); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return playlist(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return playlist?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryPlaylistImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryPlaylist implements PlaybackHistoryItem { + factory PlaybackHistoryPlaylist( + {required final DateTime date, + required final PlaylistSimple playlist}) = _$PlaybackHistoryPlaylistImpl; + + factory PlaybackHistoryPlaylist.fromJson(Map json) = + _$PlaybackHistoryPlaylistImpl.fromJson; + + @override + DateTime get date; + PlaylistSimple get playlist; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PlaybackHistoryAlbumImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryAlbumImplCopyWith(_$PlaybackHistoryAlbumImpl value, + $Res Function(_$PlaybackHistoryAlbumImpl) then) = + __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, AlbumSimple album}); +} + +/// @nodoc +class __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryAlbumImpl> + implements _$$PlaybackHistoryAlbumImplCopyWith<$Res> { + __$$PlaybackHistoryAlbumImplCopyWithImpl(_$PlaybackHistoryAlbumImpl _value, + $Res Function(_$PlaybackHistoryAlbumImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? album = null, + }) { + return _then(_$PlaybackHistoryAlbumImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + album: null == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as AlbumSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum { + _$PlaybackHistoryAlbumImpl( + {required this.date, required this.album, final String? $type}) + : $type = $type ?? 'album'; + + factory _$PlaybackHistoryAlbumImpl.fromJson(Map json) => + _$$PlaybackHistoryAlbumImplFromJson(json); + + @override + final DateTime date; + @override + final AlbumSimple album; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.album(date: $date, album: $album)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryAlbumImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.album, album) || other.album == album)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, album); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> + get copyWith => + __$$PlaybackHistoryAlbumImplCopyWithImpl<_$PlaybackHistoryAlbumImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return album(date, this.album); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return album?.call(date, this.album); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (album != null) { + return album(date, this.album); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return album(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return album?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (album != null) { + return album(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryAlbumImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryAlbum implements PlaybackHistoryItem { + factory PlaybackHistoryAlbum( + {required final DateTime date, + required final AlbumSimple album}) = _$PlaybackHistoryAlbumImpl; + + factory PlaybackHistoryAlbum.fromJson(Map json) = + _$PlaybackHistoryAlbumImpl.fromJson; + + @override + DateTime get date; + AlbumSimple get album; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PlaybackHistoryTrackImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryTrackImplCopyWith(_$PlaybackHistoryTrackImpl value, + $Res Function(_$PlaybackHistoryTrackImpl) then) = + __$$PlaybackHistoryTrackImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, Track track}); +} + +/// @nodoc +class __$$PlaybackHistoryTrackImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryTrackImpl> + implements _$$PlaybackHistoryTrackImplCopyWith<$Res> { + __$$PlaybackHistoryTrackImplCopyWithImpl(_$PlaybackHistoryTrackImpl _value, + $Res Function(_$PlaybackHistoryTrackImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? track = null, + }) { + return _then(_$PlaybackHistoryTrackImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + track: null == track + ? _value.track + : track // ignore: cast_nullable_to_non_nullable + as Track, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { + _$PlaybackHistoryTrackImpl( + {required this.date, required this.track, final String? $type}) + : $type = $type ?? 'track'; + + factory _$PlaybackHistoryTrackImpl.fromJson(Map json) => + _$$PlaybackHistoryTrackImplFromJson(json); + + @override + final DateTime date; + @override + final Track track; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.track(date: $date, track: $track)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryTrackImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.track, track) || other.track == track)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, track); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> + get copyWith => + __$$PlaybackHistoryTrackImplCopyWithImpl<_$PlaybackHistoryTrackImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return track(date, this.track); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return track?.call(date, this.track); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (track != null) { + return track(date, this.track); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return track(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return track?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (track != null) { + return track(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryTrackImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryTrack implements PlaybackHistoryItem { + factory PlaybackHistoryTrack( + {required final DateTime date, + required final Track track}) = _$PlaybackHistoryTrackImpl; + + factory PlaybackHistoryTrack.fromJson(Map json) = + _$PlaybackHistoryTrackImpl.fromJson; + + @override + DateTime get date; + Track get track; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/provider/history/state.g.dart b/lib/provider/history/state.g.dart new file mode 100644 index 00000000..dfd01c2c --- /dev/null +++ b/lib/provider/history/state.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$PlaybackHistoryPlaylistImpl _$$PlaybackHistoryPlaylistImplFromJson( + Map json) => + _$PlaybackHistoryPlaylistImpl( + date: DateTime.parse(json['date'] as String), + playlist: PlaylistSimple.fromJson( + Map.from(json['playlist'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryPlaylistImplToJson( + _$PlaybackHistoryPlaylistImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'playlist': instance.playlist.toJson(), + 'runtimeType': instance.$type, + }; + +_$PlaybackHistoryAlbumImpl _$$PlaybackHistoryAlbumImplFromJson(Map json) => + _$PlaybackHistoryAlbumImpl( + date: DateTime.parse(json['date'] as String), + album: + AlbumSimple.fromJson(Map.from(json['album'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryAlbumImplToJson( + _$PlaybackHistoryAlbumImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'album': instance.album.toJson(), + 'runtimeType': instance.$type, + }; + +_$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) => + _$PlaybackHistoryTrackImpl( + date: DateTime.parse(json['date'] as String), + track: Track.fromJson(Map.from(json['track'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryTrackImplToJson( + _$PlaybackHistoryTrackImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'track': instance.track.toJson(), + 'runtimeType': instance.$type, + }; diff --git a/lib/provider/history/summary.dart b/lib/provider/history/summary.dart new file mode 100644 index 00000000..2aa86ac9 --- /dev/null +++ b/lib/provider/history/summary.dart @@ -0,0 +1,62 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/provider/history/top.dart'; + +final playbackHistorySummaryProvider = Provider((ref) { + final (:tracks, :albums, :playlists) = + ref.watch(playbackHistoryGroupedProvider); + + final totalDurationListened = tracks.fold( + Duration.zero, + (previousValue, element) => previousValue + element.track.duration!, + ); + + final totalTracksListened = tracks + .whereIndexed( + (i, track) => + i == tracks.lastIndexWhere((e) => e.track.id == track.track.id), + ) + .length; + + final artists = + tracks.map((e) => e.track.artists).expand((e) => e ?? []).toList(); + + final totalArtistsListened = artists + .whereIndexed( + (i, artist) => i == artists.lastIndexWhere((e) => e.id == artist.id), + ) + .length; + + final totalAlbumsListened = albums + .whereIndexed( + (i, album) => + i == albums.lastIndexWhere((e) => e.album.id == album.album.id), + ) + .length; + + final totalPlaylistsListened = playlists + .whereIndexed( + (i, playlist) => + i == + playlists + .lastIndexWhere((e) => e.playlist.id == playlist.playlist.id), + ) + .length; + + final tracksThisMonth = ref.watch( + playbackHistoryTopProvider(HistoryDuration.days30).select((s) => s.tracks), + ); + + final streams = tracksThisMonth.fold(0, (acc, el) => acc + el.count); + + return ( + duration: totalDurationListened, + tracks: totalTracksListened, + artists: totalArtistsListened, + fees: streams * 0.005, // Spotify pays $0.003 to $0.005 + albums: totalAlbumsListened, + playlists: totalPlaylistsListened, + ); +}); diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart new file mode 100644 index 00000000..7d4594f0 --- /dev/null +++ b/lib/provider/history/top.dart @@ -0,0 +1,95 @@ +import 'package:collection/collection.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/history/state.dart'; + +final playbackHistoryTopDurationProvider = + StateProvider((ref) => HistoryDuration.days30); + +final playbackHistoryTopProvider = + Provider.family((ref, HistoryDuration durationState) { + final grouped = ref.watch(playbackHistoryGroupedProvider); + + final duration = switch (durationState) { + HistoryDuration.allTime => const Duration(days: 365 * 2003), + HistoryDuration.days7 => const Duration(days: 7), + HistoryDuration.days30 => const Duration(days: 30), + HistoryDuration.months6 => const Duration(days: 30 * 6), + HistoryDuration.year => const Duration(days: 365), + HistoryDuration.years2 => const Duration(days: 365 * 2), + }; + final tracks = grouped.tracks + .where( + (item) => item.date.isAfter( + DateTime.now().subtract(duration), + ), + ) + .toList(); + final albums = grouped.albums + .where( + (item) => item.date.isAfter( + DateTime.now().subtract(duration), + ), + ) + .toList(); + + final playlists = grouped.playlists + .where( + (item) => item.date.isAfter( + DateTime.now().subtract(duration), + ), + ) + .toList(); + + final tracksWithCount = groupBy( + tracks, + (track) => track.track.id!, + ) + .entries + .map((entry) { + return (count: entry.value.length, track: entry.value.first.track); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + final albumsWithTrackAlbums = [ + for (final historicAlbum in albums) historicAlbum.album, + for (final track in tracks) track.track.album! + ]; + + final albumsWithCount = groupBy(albumsWithTrackAlbums, (album) => album.id!) + .entries + .map((entry) { + return (count: entry.value.length, album: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + final artists = + tracks.map((track) => track.track.artists).expand((e) => e ?? []); + + final artistsWithCount = groupBy(artists, (artist) => artist.id!) + .entries + .map((entry) { + return (count: entry.value.length, artist: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + final playlistsWithCount = + groupBy(playlists, (playlist) => playlist.playlist.id!) + .entries + .map((entry) { + return (count: entry.value.length, playlist: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + + return ( + tracks: tracksWithCount, + albums: albumsWithCount, + artists: artistsWithCount, + playlists: playlistsWithCount, + ); +}); diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart new file mode 100644 index 00000000..867774bd --- /dev/null +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +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:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +// ignore: depend_on_referenced_packages +import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; + +const supportedAudioTypes = [ + "audio/webm", + "audio/ogg", + "audio/mpeg", + "audio/mp4", + "audio/opus", + "audio/wav", + "audio/aac", +]; + +const imgMimeToExt = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", + "image/gif": ".gif", +}; + +final localTracksProvider = + FutureProvider>>((ref) async { + try { + if (kIsWeb) return {}; + final Map> tracks = {}; + + final downloadLocation = ref.watch( + userPreferencesProvider.select((s) => s.downloadLocation), + ); + final downloadDir = Directory(downloadLocation); + if (!await downloadDir.exists()) { + await downloadDir.create(recursive: true); + } + final localLibraryLocations = ref.watch( + userPreferencesProvider.select((s) => s.localLibraryLocation), + ); + + for (var location in [downloadLocation, ...localLibraryLocations]) { + if (location.isEmpty) continue; + final entities = []; + if (await Directory(location).exists()) { + try { + entities.addAll(Directory(location).listSync(recursive: true)); + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + } + } + + final filesWithMetadata = (await Future.wait( + entities.map((e) => File(e.path)).where((file) { + final mimetype = lookupMimeType(file.path); + return mimetype != null && supportedAudioTypes.contains(mimetype); + }).map( + (file) async { + try { + final metadata = await MetadataGod.readMetadata(file: file.path); + + final imageFile = File(join( + (await getTemporaryDirectory()).path, + "spotube", + basenameWithoutExtension(file.path) + + imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, + )); + if (!await imageFile.exists() && metadata.picture != null) { + await imageFile.create(recursive: true); + await imageFile.writeAsBytes( + metadata.picture?.data ?? [], + mode: FileMode.writeOnly, + ); + } + + return { + "metadata": metadata, + "file": file, + "art": imageFile.path + }; + } catch (e, stack) { + if (e is FfiException) { + return {"file": file}; + } + Catcher2.reportCheckedError(e, stack); + return {}; + } + }, + ), + )) + .where((e) => e.isNotEmpty) + .toList(); + + // ignore: no_leading_underscores_for_local_identifiers + final _tracks = filesWithMetadata + .map( + (fileWithMetadata) => LocalTrack.fromTrack( + track: Track().fromFile( + fileWithMetadata["file"], + metadata: fileWithMetadata["metadata"], + art: fileWithMetadata["art"], + ), + path: fileWithMetadata["file"].path, + ), + ) + .toList(); + + tracks[location] = _tracks; + } + return tracks; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + return {}; + } +}); diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index f86ad3d4..3ee815e6 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -1,26 +1,52 @@ -// ignore_for_file: invalid_use_of_protected_member +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member import 'dart:async'; import 'package:catcher_2/catcher_2.dart'; +import 'package:palette_generator/palette_generator.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/skip_segments.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; extension ProxyPlaylistListeners on ProxyPlaylistNotifier { + Future updatePalette() async { + final palette = ref.read(paletteProvider); + if (!preferences.albumColorSync) { + if (palette != null) ref.read(paletteProvider.notifier).state = null; + return; + } + return Future.microtask(() async { + if (playlist.activeTrack == null) return; + + final palette = await PaletteGenerator.fromImageProvider( + UniversalImage.imageProvider( + (playlist.activeTrack?.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + height: 50, + width: 50, + ), + ); + ref.read(paletteProvider.notifier).state = palette; + }); + } + StreamSubscription subscribeToPlaylist() { - return audioPlayer.playlistStream.listen((playlist) { - state = state.copyWith( - tracks: playlist.medias + return audioPlayer.playlistStream.listen((mpvPlaylist) { + state = playlist.copyWith( + tracks: mpvPlaylist.medias .map((media) => SpotubeMedia.fromMedia(media).track) .toSet(), - active: playlist.index, + active: mpvPlaylist.index, ); - notificationService.addTrack(state.activeTrack!); - discord.updatePresence(state.activeTrack!); + notificationService.addTrack(playlist.activeTrack!); + discord.updatePresence(playlist.activeTrack!); updatePalette(); }); } @@ -46,17 +72,18 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { String? lastScrobbled; return audioPlayer.positionStream.listen((position) { try { - final uid = state.activeTrack is LocalTrack - ? (state.activeTrack as LocalTrack).path - : state.activeTrack?.id; + final uid = playlist.activeTrack is LocalTrack + ? (playlist.activeTrack as LocalTrack).path + : playlist.activeTrack?.id; - if (state.activeTrack == null || + if (playlist.activeTrack == null || lastScrobbled == uid || position.inSeconds < 30) { return; } - scrobbler.scrobble(state.activeTrack!); + scrobbler.scrobble(playlist.activeTrack!); + history.addTrack(playlist.activeTrack!); lastScrobbled = uid; } catch (e, stack) { Catcher2.reportCheckedError(e, stack); @@ -68,9 +95,9 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { String lastTrack = ""; // used to prevent multiple calls to the same track return audioPlayer.positionStream.listen((event) async { if (event < const Duration(seconds: 3) || - state.active == null || - state.active == state.tracks.length - 1) return; - final nextTrack = state.tracks.elementAt(state.active! + 1); + playlist.active == null || + playlist.active == playlist.tracks.length - 1) return; + final nextTrack = playlist.tracks.elementAt(playlist.active! + 1); if (lastTrack == nextTrack.id || nextTrack is LocalTrack) return; diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index f70301ff..9f371b7a 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -1,6 +1,5 @@ import 'package:collection/collection.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -45,7 +44,14 @@ class ProxyPlaylist { } bool containsTrack(TrackSimple track) { - return tracks.firstWhereOrNull((element) => element.id == track.id) != null; + return tracks.firstWhereOrNull((element) { + if (element is LocalTrack && track is LocalTrack) { + return element.path == track.path; + } + + return element.id == track.id; + }) != + null; } bool containsTracks(Iterable tracks) { @@ -64,9 +70,11 @@ class ProxyPlaylist { /// To make sure proper instance method is used for JSON serialization /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { - return switch (track.runtimeType) { - LocalTrack() => track.toJson(), - SourcedTrack() => track.toJson(), + return switch (track) { + // ignore: unnecessary_cast + LocalTrack() => (track as LocalTrack).toJson(), + // ignore: unnecessary_cast + SourcedTrack() => (track as SourcedTrack).toJson(), _ => track.toJson(), }; } diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index 9811a1f8..c8eb3657 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -2,14 +2,12 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; @@ -32,6 +30,8 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { ProxyPlaylist get playlist => state; BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier); Discord get discord => ref.read(discordProvider); + PlaybackHistoryNotifier get history => + ref.read(playbackHistoryProvider.notifier); List _subscriptions = []; @@ -167,28 +167,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { discord.clear(); } - Future updatePalette() async { - final palette = ref.read(paletteProvider); - if (!preferences.albumColorSync) { - if (palette != null) ref.read(paletteProvider.notifier).state = null; - return; - } - return Future.microtask(() async { - if (state.activeTrack == null) return; - - final palette = await PaletteGenerator.fromImageProvider( - UniversalImage.imageProvider( - (state.activeTrack?.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - height: 50, - width: 50, - ), - ); - ref.read(paletteProvider.notifier).state = palette; - }); - } - @override set state(state) { super.state = state; diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart index 2d90eea6..7f3d1e9a 100644 --- a/lib/provider/proxy_playlist/skip_segments.dart +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -1,12 +1,11 @@ -import 'dart:convert'; - import 'package:catcher_2/catcher_2.dart'; +import 'package:dio/dio.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/dio/dio.dart'; class SourcedSegments { final String source; @@ -30,29 +29,35 @@ Future> getAndCacheSkipSegments(String id) async { ); } - final res = await get(Uri( - scheme: "https", - host: "sponsor.ajay.app", - path: "/api/skipSegments", - queryParameters: { - "videoID": id, - "category": [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'music_offtopic' - ], - "actionType": 'skip' - }, - )); + final res = await globalDio.getUri( + Uri( + scheme: "https", + host: "sponsor.ajay.app", + path: "/api/skipSegments", + queryParameters: { + "videoID": id, + "category": [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'music_offtopic' + ], + "actionType": 'skip' + }, + ), + options: Options( + responseType: ResponseType.json, + validateStatus: (status) => (status ?? 0) < 500, + ), + ); - if (res.body == "Not Found") { + if (res.data == "Not Found") { return List.castFrom([]); } - final data = jsonDecode(res.body) as List; + final data = res.data as List; final segments = data.map((obj) { final start = obj["segment"].first.toInt(); final end = obj["segment"].last.toInt(); diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index 6ce74ae7..04a2ddca 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -9,29 +9,34 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier Track get _track => arg!; Future getSpotifyLyrics(String? token) async { - final res = await http.get( - Uri.parse( - "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", - ), + final res = await globalDio.getUri( + Uri.parse( + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", + ), + options: Options( headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", "App-platform": "WebPlayer", "authorization": "Bearer $token" - }); + }, + responseType: ResponseType.json, + validateStatus: (status) => true, + ), + ); if (res.statusCode != 200) { return SubtitleSimple( lyrics: [], name: _track.name!, - uri: res.request!.url, + uri: res.realUri, rating: 0, provider: "Spotify", ); } - final linesRaw = Map.castFrom( - jsonDecode(res.body), - )["lyrics"]?["lines"] as List?; + final linesRaw = + Map.castFrom(res.data)["lyrics"] + ?["lines"] as List?; final lines = linesRaw?.map((line) { return LyricSlice( @@ -44,7 +49,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: lines, name: _track.name!, - uri: res.request!.url, + uri: res.realUri, rating: 100, provider: "Spotify", ); @@ -55,7 +60,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier Future getLRCLibLyrics() async { final packageInfo = await PackageInfo.fromPlatform(); - final res = await http.get( + final res = await globalDio.getUri( Uri( scheme: "https", host: "lrclib.net", @@ -67,23 +72,26 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier "duration": _track.duration?.inSeconds.toString(), }, ), - headers: { - "User-Agent": - "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" - }, + options: Options( + headers: { + "User-Agent": + "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" + }, + responseType: ResponseType.json, + ), ); if (res.statusCode != 200) { return SubtitleSimple( lyrics: [], name: _track.name!, - uri: res.request!.url, + uri: res.realUri, rating: 0, provider: "LRCLib", ); } - final json = jsonDecode(res.body) as Map; + final json = res.data as Map; final syncedLyricsRaw = json["syncedLyrics"] as String?; final syncedLyrics = syncedLyricsRaw?.isNotEmpty == true @@ -97,7 +105,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: syncedLyrics!, name: _track.name!, - uri: res.request!.url, + uri: res.realUri, rating: 100, provider: "LRCLib", ); @@ -111,7 +119,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: plainLyrics, name: _track.name!, - uri: res.request!.url, + uri: res.realUri, rating: 0, provider: "LRCLib", ); @@ -127,7 +135,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier final token = await spotify.getCredentials(); SubtitleSimple lyrics = await getSpotifyLyrics(token.accessToken); - if (lyrics.lyrics.isEmpty) { + if (lyrics.lyrics.isEmpty || lyrics.lyrics.length <= 5) { lyrics = await getLRCLibLyrics(); } diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index 816420f6..ac83ba72 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -1,10 +1,10 @@ library spotify; import 'dart:async'; -import 'dart:convert'; import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; @@ -23,9 +23,9 @@ import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/wikipedia/wikipedia.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:http/http.dart' as http; import 'package:wikipedia_api/wikipedia_api.dart'; diff --git a/lib/provider/tray_manager/tray_manager.dart b/lib/provider/tray_manager/tray_manager.dart new file mode 100644 index 00000000..2145cbef --- /dev/null +++ b/lib/provider/tray_manager/tray_manager.dart @@ -0,0 +1,79 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/tray_manager/tray_menu.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; + +class SystemTrayManager with TrayListener { + final Ref ref; + final bool enabled; + + SystemTrayManager( + this.ref, { + required this.enabled, + }) { + initialize(); + } + + Future initialize() async { + if (!kIsDesktop) return; + + if (enabled) { + await trayManager.setIcon( + kIsWindows + ? 'assets/spotube-logo.ico' + : kIsFlatpak + ? 'com.github.KRTirtho.Spotube.png' + : 'assets/spotube-logo.png', + ); + trayManager.addListener(this); + } else { + await trayManager.destroy(); + } + } + + void dispose() { + trayManager.removeListener(this); + } + + @override + onTrayIconMouseDown() { + if (kIsWindows) { + windowManager.show(); + } else { + trayManager.popUpContextMenu(); + } + } + + @override + onTrayIconRightMouseDown() { + if (!kIsWindows) { + windowManager.show(); + } else { + trayManager.popUpContextMenu(); + } + } +} + +final trayManagerProvider = Provider( + (ref) { + final enabled = ref.watch( + userPreferencesProvider.select((s) => s.showSystemTrayIcon), + ); + + ref.listen(trayMenuProvider, (_, menu) { + if (!enabled || !kIsDesktop) return; + trayManager.setContextMenu(menu); + }); + + final manager = SystemTrayManager( + ref, + enabled: enabled, + ); + + ref.onDispose(manager.dispose); + + return manager; + }, +); diff --git a/lib/provider/tray_manager/tray_menu.dart b/lib/provider/tray_manager/tray_menu.dart new file mode 100644 index 00000000..cb793707 --- /dev/null +++ b/lib/provider/tray_manager/tray_menu.dart @@ -0,0 +1,108 @@ +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; + +final audioPlayerLoopMode = StreamProvider((ref) { + return audioPlayer.loopModeStream; +}); + +final audioPlayerShuffleMode = StreamProvider((ref) { + return audioPlayer.shuffledStream; +}); +final audioPlayerPlaying = StreamProvider((ref) { + return audioPlayer.playingStream; +}); + +final trayMenuProvider = Provider((ref) { + final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final isPlaybackPlaying = + ref.watch(proxyPlaylistProvider.select((s) => s.activeTrack != null)); + final isLoopOne = + ref.watch(audioPlayerLoopMode).asData?.value == PlaybackLoopMode.one; + final isShuffled = ref.watch(audioPlayerShuffleMode).asData?.value ?? false; + final isPlaying = ref.watch(audioPlayerPlaying).asData?.value ?? false; + + return Menu( + items: [ + MenuItem( + label: "Show/Hide Window", + onClick: (menuItem) async { + if (await windowManager.isVisible()) { + await windowManager.hide(); + } else { + await windowManager.focus(); + await windowManager.show(); + } + }, + ), + MenuItem.separator(), + MenuItem( + label: isPlaying ? "Pause" : "Play", + disabled: !isPlaybackPlaying, + onClick: (menuItem) async { + if (audioPlayer.isPlaying) { + await audioPlayer.pause(); + } else { + await audioPlayer.resume(); + } + }, + ), + MenuItem( + label: "Next", + disabled: !isPlaybackPlaying, + onClick: (menuItem) { + playlistNotifier.next(); + }, + ), + MenuItem( + label: "Previous", + disabled: !isPlaybackPlaying, + onClick: (menuItem) { + playlistNotifier.previous(); + }, + ), + MenuItem.submenu( + label: "Playback", + submenu: Menu( + items: [ + MenuItem( + label: "Repeat", + checked: isLoopOne, + onClick: (menuItem) { + audioPlayer.setLoopMode( + isLoopOne ? PlaybackLoopMode.none : PlaybackLoopMode.one, + ); + }, + ), + MenuItem( + label: "Shuffle", + checked: isShuffled, + onClick: (menuItem) { + audioPlayer.setShuffle(!isShuffled); + }, + ), + MenuItem.separator(), + MenuItem( + label: "Stop", + onClick: (menuItem) { + playlistNotifier.stop(); + }, + ), + ], + ), + ), + MenuItem.separator(), + MenuItem( + label: "Quit", + onClick: (menuItem) { + exit(0); + }, + ), + ], + ); +}); diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index a1e247b2..fe726915 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/provider/palette_provider.dart'; +import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -15,6 +15,7 @@ import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; import 'package:path/path.dart' as path; +import 'package:window_manager/window_manager.dart'; class UserPreferencesNotifier extends PersistedStateNotifier { final Ref ref; @@ -69,6 +70,11 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(downloadLocation: downloadDir); } + void setLocalLibraryLocation(List localLibraryDirs) { + //if (localLibraryDir.isEmpty) return; + state = state.copyWith(localLibraryLocation: localLibraryDirs); + } + void setLayoutMode(LayoutMode mode) { state = state.copyWith(layoutMode: mode); } @@ -103,8 +109,8 @@ class UserPreferencesNotifier extends PersistedStateNotifier { void setSystemTitleBar(bool isSystemTitleBar) { state = state.copyWith(systemTitleBar: isSystemTitleBar); - if (DesktopTools.platform.isDesktop) { - DesktopTools.window.setTitleBarStyle( + if (kIsDesktop) { + windowManager.setTitleBarStyle( isSystemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, ); } @@ -151,8 +157,8 @@ class UserPreferencesNotifier extends PersistedStateNotifier { ); } - if (DesktopTools.platform.isDesktop) { - await DesktopTools.window.setTitleBarStyle( + if (kIsDesktop) { + await windowManager.setTitleBarStyle( state.systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, ); } diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index e35c73b5..56f66375 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -62,10 +62,10 @@ class UserPreferences with _$UserPreferences { @Default(false) bool amoledDarkTheme, @Default(true) bool checkUpdate, @Default(false) bool normalizeAudio, - @Default(true) bool showSystemTrayIcon, + @Default(false) bool showSystemTrayIcon, @Default(false) bool skipNonMusic, @Default(false) bool systemTitleBar, - @Default(CloseBehavior.minimizeToTray) CloseBehavior closeBehavior, + @Default(CloseBehavior.close) CloseBehavior closeBehavior, @Default(SpotubeColor(0xFF2196F3, name: "Blue")) @JsonKey( fromJson: UserPreferences._accentColorSchemeFromJson, @@ -84,6 +84,7 @@ class UserPreferences with _$UserPreferences { @Default(Market.US) Market recommendationMarket, @Default(SearchMode.youtube) SearchMode searchMode, @Default("") String downloadLocation, + @Default([]) List localLibraryLocation, @Default("https://pipedapi.kavin.rocks") String pipedInstance, @Default(ThemeMode.system) ThemeMode themeMode, @Default(AudioSource.youtube) AudioSource audioSource, diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart index a5b076bb..89c7210a 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -12,7 +12,7 @@ part of 'user_preferences_state.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); UserPreferences _$UserPreferencesFromJson(Map json) { return _UserPreferences.fromJson(json); @@ -43,6 +43,7 @@ mixin _$UserPreferences { Market get recommendationMarket => throw _privateConstructorUsedError; SearchMode get searchMode => throw _privateConstructorUsedError; String get downloadLocation => throw _privateConstructorUsedError; + List get localLibraryLocation => throw _privateConstructorUsedError; String get pipedInstance => throw _privateConstructorUsedError; ThemeMode get themeMode => throw _privateConstructorUsedError; AudioSource get audioSource => throw _privateConstructorUsedError; @@ -88,6 +89,7 @@ abstract class $UserPreferencesCopyWith<$Res> { Market recommendationMarket, SearchMode searchMode, String downloadLocation, + List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, @@ -126,6 +128,7 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> Object? recommendationMarket = null, Object? searchMode = null, Object? downloadLocation = null, + Object? localLibraryLocation = null, Object? pipedInstance = null, Object? themeMode = null, Object? audioSource = null, @@ -196,6 +199,10 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> ? _value.downloadLocation : downloadLocation // ignore: cast_nullable_to_non_nullable as String, + localLibraryLocation: null == localLibraryLocation + ? _value.localLibraryLocation + : localLibraryLocation // ignore: cast_nullable_to_non_nullable + as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -264,6 +271,7 @@ abstract class _$$UserPreferencesImplCopyWith<$Res> Market recommendationMarket, SearchMode searchMode, String downloadLocation, + List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, @@ -300,6 +308,7 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> Object? recommendationMarket = null, Object? searchMode = null, Object? downloadLocation = null, + Object? localLibraryLocation = null, Object? pipedInstance = null, Object? themeMode = null, Object? audioSource = null, @@ -370,6 +379,10 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> ? _value.downloadLocation : downloadLocation // ignore: cast_nullable_to_non_nullable as String, + localLibraryLocation: null == localLibraryLocation + ? _value._localLibraryLocation + : localLibraryLocation // ignore: cast_nullable_to_non_nullable + as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -415,10 +428,10 @@ class _$UserPreferencesImpl implements _UserPreferences { this.amoledDarkTheme = false, this.checkUpdate = true, this.normalizeAudio = false, - this.showSystemTrayIcon = true, + this.showSystemTrayIcon = false, this.skipNonMusic = false, this.systemTitleBar = false, - this.closeBehavior = CloseBehavior.minimizeToTray, + this.closeBehavior = CloseBehavior.close, @JsonKey( fromJson: UserPreferences._accentColorSchemeFromJson, toJson: UserPreferences._accentColorSchemeToJson, @@ -433,6 +446,7 @@ class _$UserPreferencesImpl implements _UserPreferences { this.recommendationMarket = Market.US, this.searchMode = SearchMode.youtube, this.downloadLocation = "", + final List localLibraryLocation = const [], this.pipedInstance = "https://pipedapi.kavin.rocks", this.themeMode = ThemeMode.system, this.audioSource = AudioSource.youtube, @@ -440,7 +454,8 @@ class _$UserPreferencesImpl implements _UserPreferences { this.downloadMusicCodec = SourceCodecs.m4a, this.discordPresence = true, this.endlessPlayback = true, - this.enableConnect = false}); + this.enableConnect = false}) + : _localLibraryLocation = localLibraryLocation; factory _$UserPreferencesImpl.fromJson(Map json) => _$$UserPreferencesImplFromJson(json); @@ -496,6 +511,16 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final String downloadLocation; + final List _localLibraryLocation; + @override + @JsonKey() + List get localLibraryLocation { + if (_localLibraryLocation is EqualUnmodifiableListView) + return _localLibraryLocation; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_localLibraryLocation); + } + @override @JsonKey() final String pipedInstance; @@ -523,7 +548,7 @@ class _$UserPreferencesImpl implements _UserPreferences { @override String toString() { - return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; + return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, localLibraryLocation: $localLibraryLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; } @override @@ -560,6 +585,8 @@ class _$UserPreferencesImpl implements _UserPreferences { other.searchMode == searchMode) && (identical(other.downloadLocation, downloadLocation) || other.downloadLocation == downloadLocation) && + const DeepCollectionEquality() + .equals(other._localLibraryLocation, _localLibraryLocation) && (identical(other.pipedInstance, pipedInstance) || other.pipedInstance == pipedInstance) && (identical(other.themeMode, themeMode) || @@ -597,6 +624,7 @@ class _$UserPreferencesImpl implements _UserPreferences { recommendationMarket, searchMode, downloadLocation, + const DeepCollectionEquality().hash(_localLibraryLocation), pipedInstance, themeMode, audioSource, @@ -647,6 +675,7 @@ abstract class _UserPreferences implements UserPreferences { final Market recommendationMarket, final SearchMode searchMode, final String downloadLocation, + final List localLibraryLocation, final String pipedInstance, final ThemeMode themeMode, final AudioSource audioSource, @@ -698,6 +727,8 @@ abstract class _UserPreferences implements UserPreferences { @override String get downloadLocation; @override + List get localLibraryLocation; + @override String get pipedInstance; @override ThemeMode get themeMode; diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index 8bdd12cc..4bcb3a46 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -6,8 +6,7 @@ part of 'user_preferences_state.dart'; // JsonSerializableGenerator // ************************************************************************** -_$UserPreferencesImpl _$$UserPreferencesImplFromJson( - Map json) => +_$UserPreferencesImpl _$$UserPreferencesImplFromJson(Map json) => _$UserPreferencesImpl( audioQuality: $enumDecodeNullable(_$SourceQualitiesEnumMap, json['audioQuality']) ?? @@ -16,12 +15,12 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, checkUpdate: json['checkUpdate'] as bool? ?? true, normalizeAudio: json['normalizeAudio'] as bool? ?? false, - showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? true, + showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? false, skipNonMusic: json['skipNonMusic'] as bool? ?? false, systemTitleBar: json['systemTitleBar'] as bool? ?? false, closeBehavior: $enumDecodeNullable(_$CloseBehaviorEnumMap, json['closeBehavior']) ?? - CloseBehavior.minimizeToTray, + CloseBehavior.close, accentColorScheme: UserPreferences._accentColorSchemeReadValue( json, 'accentColorScheme') == null @@ -44,6 +43,10 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson( $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? SearchMode.youtube, downloadLocation: json['downloadLocation'] as String? ?? "", + localLibraryLocation: (json['localLibraryLocation'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], pipedInstance: json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks", themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? @@ -81,6 +84,7 @@ Map _$$UserPreferencesImplToJson( 'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!, 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, 'downloadLocation': instance.downloadLocation, + 'localLibraryLocation': instance.localLibraryLocation, 'pipedInstance': instance.pipedInstance, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index a81c6c95..8d3e0bfb 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:catcher_2/catcher_2.dart'; import 'package:flutter/foundation.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/services/audio_player/custom_player.dart'; @@ -13,6 +12,7 @@ import 'package:media_kit/media_kit.dart' as mk; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; +import 'package:spotube/services/sourced_track/sourced_track.dart'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; @@ -30,12 +30,18 @@ class SpotubeMedia extends mk.Media { : "http://${InternetAddress.loopbackIPv4.address}:${PlaybackServer.port}/stream/${track.id}", extras: { ...?extras, - "track": track.toJson(), + "track": switch (track) { + LocalTrack() => track.toJson(), + SourcedTrack() => track.toJson(), + _ => track.toJson(), + }, }, ); factory SpotubeMedia.fromMedia(mk.Media media) { - final track = Track.fromJson(media.extras?["track"]); + final track = media.uri.startsWith("http") + ? Track.fromJson(media.extras?["track"]) + : LocalTrack.fromJson(media.extras?["track"]); return SpotubeMedia(track); } } @@ -101,7 +107,7 @@ abstract class AudioPlayerInterface { return _mkPlayer.state.completed; } - Future get isShuffled async { + bool get isShuffled { return _mkPlayer.shuffled; } diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart index 916a983f..e32a0d14 100644 --- a/lib/services/audio_player/custom_player.dart +++ b/lib/services/audio_player/custom_player.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:catcher_2/catcher_2.dart'; import 'package:media_kit/media_kit.dart'; import 'package:flutter_broadcasts/flutter_broadcasts.dart'; @@ -7,6 +6,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:audio_session/audio_session.dart'; // ignore: implementation_imports import 'package:spotube/services/audio_player/playback_state.dart'; +import 'package:spotube/utils/platform.dart'; /// MediaKit [Player] by default doesn't have a state stream. /// This class adds a state stream to the [Player] class. @@ -54,7 +54,7 @@ class CustomPlayer extends Player { PackageInfo.fromPlatform().then((packageInfo) { _packageName = packageInfo.packageName; }); - if (DesktopTools.platform.isAndroid) { + if (kIsAndroid) { _androidAudioManager = AndroidAudioManager(); AudioSession.instance.then((s) async { _androidAudioSessionId = @@ -71,7 +71,7 @@ class CustomPlayer extends Player { } Future notifyAudioSessionUpdate(bool active) async { - if (DesktopTools.platform.isAndroid) { + if (kIsAndroid) { sendBroadcast( BroadcastMessage( name: active diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 338427aa..f42d6c4b 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -1,5 +1,4 @@ import 'package:audio_service/audio_service.dart'; -import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/artist_simple.dart'; @@ -8,6 +7,7 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/utils/platform.dart'; class AudioServices { final MobileAudioService? mobile; @@ -19,9 +19,7 @@ class AudioServices { Ref ref, ProxyPlaylistNotifier playback, ) async { - final mobile = DesktopTools.platform.isMobile || - DesktopTools.platform.isMacOS || - DesktopTools.platform.isLinux + final mobile = kIsMobile || kIsMacOS || kIsLinux ? await AudioService.init( builder: () => MobileAudioService(playback), config: const AudioServiceConfig( @@ -31,9 +29,7 @@ class AudioServices { ), ) : null; - final smtc = DesktopTools.platform.isWindows - ? WindowsAudioService(ref, playback) - : null; + final smtc = kIsWindows ? WindowsAudioService(ref, playback) : null; return AudioServices( mobile, diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 3bb88447..62cc8552 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -11,7 +11,7 @@ class MobileAudioService extends BaseAudioHandler { AudioSession? session; final ProxyPlaylistNotifier playlistNotifier; - // ignore: invalid_use_of_protected_member + // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member ProxyPlaylist get playlist => playlistNotifier.state; MobileAudioService(this.playlistNotifier) { @@ -135,7 +135,7 @@ class MobileAudioService extends BaseAudioHandler { playing: audioPlayer.isPlaying, updatePosition: position, bufferedPosition: await audioPlayer.bufferedPosition ?? Duration.zero, - shuffleMode: await audioPlayer.isShuffled == true + shuffleMode: audioPlayer.isShuffled == true ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, repeatMode: (audioPlayer.loopMode).toAudioServiceRepeatMode(), diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index d8600366..0c7daeb2 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:http/http.dart' as http; +import 'package:dio/dio.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; @@ -9,9 +9,21 @@ import 'package:timezone/timezone.dart' as tz; class CustomSpotifyEndpoints { static const _baseUrl = 'https://api.spotify.com/v1'; final String accessToken; - final http.Client _client; + final Dio _client; - CustomSpotifyEndpoints(this.accessToken) : _client = http.Client(); + CustomSpotifyEndpoints(this.accessToken) + : _client = Dio( + BaseOptions( + baseUrl: _baseUrl, + responseType: ResponseType.json, + headers: { + "content-type": "application/json", + if (accessToken.isNotEmpty) + "authorization": "Bearer $accessToken", + "accept": "application/json", + }, + ), + ); // views API @@ -65,44 +77,34 @@ class CustomSpotifyEndpoints { if (country != null) 'country': country.name, }.entries.map((e) => '${e.key}=${e.value}').join('&'); - final res = await _client.get( + final res = await _client.getUri( Uri.parse('$_baseUrl/views/$view?$queryParams'), - headers: { - "content-type": "application/json", - "authorization": "Bearer $accessToken", - "accept": "application/json", - }, ); if (res.statusCode == 200) { - return jsonDecode(res.body); + return res.data; } else { throw Exception( '[CustomSpotifyEndpoints.getView]: Failed to get view' '\nStatus code: ${res.statusCode}' - '\nBody: ${res.body}', + '\nBody: ${res.data}', ); } } Future> listGenreSeeds() async { - final res = await _client.get( + final res = await _client.getUri( Uri.parse("$_baseUrl/recommendations/available-genre-seeds"), - headers: { - "content-type": "application/json", - if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", - "accept": "application/json", - }, ); if (res.statusCode == 200) { - final body = jsonDecode(res.body); + final body = res.data; return List.from(body["genres"] ?? []); } else { throw Exception( '[CustomSpotifyEndpoints.listGenreSeeds]: Failed to get genre seeds' '\nStatus code: ${res.statusCode}' - '\nBody: ${res.body}', + '\nBody: ${res.data}', ); } } @@ -152,30 +154,18 @@ class CustomSpotifyEndpoints { } final pathQuery = "$_baseUrl/recommendations?${parameters.entries.map((e) => '${e.key}=${e.value}').join('&')}"; - final res = await _client.get( - Uri.parse(pathQuery), - headers: { - "content-type": "application/json", - if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", - "accept": "application/json", - }, - ); - final result = jsonDecode(res.body); + final res = await _client.getUri(Uri.parse(pathQuery)); + final result = res.data; return List.castFrom( result["tracks"].map((track) => Track.fromJson(track)).toList(), ); } Future getFriendActivity() async { - final res = await _client.get( + final res = await _client.getUri( Uri.parse("https://guc-spclient.spotify.com/presence-view/v1/buddylist"), - headers: { - "content-type": "application/json", - "authorization": "Bearer $accessToken", - "accept": "application/json", - }, ); - return SpotifyFriends.fromJson(jsonDecode(res.body)); + return SpotifyFriends.fromJson(res.data); } Future getHomeFeed({ @@ -190,7 +180,7 @@ class CustomSpotifyEndpoints { 'origin': 'https://open.spotify.com', 'referer': 'https://open.spotify.com/' }; - final response = await http.get( + final response = await _client.getUri( Uri( scheme: "https", host: "api-partner.spotify.com", @@ -219,21 +209,11 @@ class CustomSpotifyEndpoints { ), }, ), - headers: headers, + options: Options(headers: headers), ); - if (response.statusCode >= 400) { - throw Exception( - "[RequestException] " - "Status: ${response.statusCode}\n" - "Body: ${response.body}", - ); - } - final data = SpotifyHomeFeed.fromJson( - transformHomeFeedJsonMap( - jsonDecode(response.body), - ), + transformHomeFeedJsonMap(response.data), ); return data; @@ -252,7 +232,7 @@ class CustomSpotifyEndpoints { 'origin': 'https://open.spotify.com', 'referer': 'https://open.spotify.com/' }; - final response = await http.get( + final response = await _client.getUri( Uri( scheme: "https", host: "api-partner.spotify.com", @@ -280,20 +260,12 @@ class CustomSpotifyEndpoints { ), }, ), - headers: headers, + options: Options(headers: headers), ); - if (response.statusCode >= 400) { - throw Exception( - "[RequestException] " - "Status: ${response.statusCode}\n" - "Body: ${response.body}", - ); - } - final data = SpotifyHomeFeedSection.fromJson( transformSectionItemJsonMap( - jsonDecode(response.body)["data"]["homeSections"]["sections"][0], + response.data["data"]["homeSections"]["sections"][0], ), ); diff --git a/lib/services/dio/dio.dart b/lib/services/dio/dio.dart new file mode 100644 index 00000000..cddf1979 --- /dev/null +++ b/lib/services/dio/dio.dart @@ -0,0 +1,3 @@ +import 'package:dio/dio.dart'; + +final globalDio = Dio(); diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index f94ec4ee..ae62a055 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -1,4 +1,7 @@ +import 'dart:convert'; + import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/services/wm_tools/wm_tools.dart'; abstract class KVStoreService { static SharedPreferences? _sharedPreferences; @@ -23,4 +26,21 @@ abstract class KVStoreService { static Future setRecentSearches(List value) async => await sharedPreferences.setStringList('recentSearches', value); + + static WindowSize? get windowSize { + final raw = sharedPreferences.getString('windowSize'); + + if (raw == null) { + return null; + } + return WindowSize.fromJson(jsonDecode(raw)); + } + + static Future setWindowSize(WindowSize value) async => + await sharedPreferences.setString( + 'windowSize', + jsonEncode( + value.toJson(), + ), + ); } diff --git a/lib/services/song_link/song_link.freezed.dart b/lib/services/song_link/song_link.freezed.dart index a8230eeb..0a1af8a9 100644 --- a/lib/services/song_link/song_link.freezed.dart +++ b/lib/services/song_link/song_link.freezed.dart @@ -12,7 +12,7 @@ part of 'song_link.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); SongLink _$SongLinkFromJson(Map json) { return _SongLink.fromJson(json); diff --git a/lib/services/song_link/song_link.g.dart b/lib/services/song_link/song_link.g.dart index 911849e3..7658a74c 100644 --- a/lib/services/song_link/song_link.g.dart +++ b/lib/services/song_link/song_link.g.dart @@ -6,8 +6,7 @@ part of 'song_link.dart'; // JsonSerializableGenerator // ************************************************************************** -_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => - _$SongLinkImpl( +_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => _$SongLinkImpl( displayName: json['displayName'] as String, linkId: json['linkId'] as String, platform: json['platform'] as String, diff --git a/lib/services/sourced_track/models/source_info.g.dart b/lib/services/sourced_track/models/source_info.g.dart index 1ec9f75f..5fe136ce 100644 --- a/lib/services/sourced_track/models/source_info.g.dart +++ b/lib/services/sourced_track/models/source_info.g.dart @@ -6,7 +6,7 @@ part of 'source_info.dart'; // JsonSerializableGenerator // ************************************************************************** -SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( +SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( id: json['id'] as String, title: json['title'] as String, artist: json['artist'] as String, diff --git a/lib/services/sourced_track/models/source_map.g.dart b/lib/services/sourced_track/models/source_map.g.dart index e1085aa8..a581cc67 100644 --- a/lib/services/sourced_track/models/source_map.g.dart +++ b/lib/services/sourced_track/models/source_map.g.dart @@ -6,8 +6,7 @@ part of 'source_map.dart'; // JsonSerializableGenerator // ************************************************************************** -SourceQualityMap _$SourceQualityMapFromJson(Map json) => - SourceQualityMap( +SourceQualityMap _$SourceQualityMapFromJson(Map json) => SourceQualityMap( high: json['high'] as String, medium: json['medium'] as String, low: json['low'] as String, @@ -20,16 +19,18 @@ Map _$SourceQualityMapToJson(SourceQualityMap instance) => 'low': instance.low, }; -SourceMap _$SourceMapFromJson(Map json) => SourceMap( +SourceMap _$SourceMapFromJson(Map json) => SourceMap( weba: json['weba'] == null ? null - : SourceQualityMap.fromJson(json['weba'] as Map), + : SourceQualityMap.fromJson( + Map.from(json['weba'] as Map)), m4a: json['m4a'] == null ? null - : SourceQualityMap.fromJson(json['m4a'] as Map), + : SourceQualityMap.fromJson( + Map.from(json['m4a'] as Map)), ); Map _$SourceMapToJson(SourceMap instance) => { - 'weba': instance.weba, - 'm4a': instance.m4a, + 'weba': instance.weba?.toJson(), + 'm4a': instance.m4a?.toJson(), }; diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index a5e094ed..7eedfad8 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -135,16 +135,10 @@ abstract class SourcedTrack extends Track { return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); } catch (e) { if (e is DioException || e is ClientException || e is SocketException) { - if (preferences.audioSource == AudioSource.jiosaavn) { - return await JioSaavnSourcedTrack.fetchFromTrack( - track: track, - ref: ref, - weakMatch: true, - ); - } return await JioSaavnSourcedTrack.fetchFromTrack( track: track, ref: ref, + weakMatch: preferences.audioSource == AudioSource.jiosaavn, ); } rethrow; diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index 75f83125..8444db53 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -163,7 +163,7 @@ class PipedSourcedTrack extends SourcedTrack { final PipedSearchResult(items: searchResults) = await pipedClient.search( query, preference.searchMode == SearchMode.youtube - ? PipedFilter.videos + ? PipedFilter.video : PipedFilter.musicSongs, ); diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 3fc78f0b..c24edfc0 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -1,3 +1,4 @@ +import 'package:catcher_2/core/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart'; @@ -221,14 +222,19 @@ class YoutubeSourcedTrack extends SourcedTrack { final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube"); if (ytLink?.url != null) { - return [ - await toSiblingType( - 0, - YoutubeVideoInfo.fromVideo( - await youtubeClient.videos.get(ytLink!.url!), - ), - ) - ]; + try { + return [ + await toSiblingType( + 0, + YoutubeVideoInfo.fromVideo( + await youtubeClient.videos.get(ytLink!.url!), + ), + ) + ]; + } on VideoUnplayableException catch (e, stack) { + // Ignore this error and continue with the search + Catcher2.reportCheckedError(e, stack); + } } final query = SourcedTrack.getSearchTerm(track); diff --git a/lib/services/wm_tools/wm_tools.dart b/lib/services/wm_tools/wm_tools.dart new file mode 100644 index 00000000..4572a8b4 --- /dev/null +++ b/lib/services/wm_tools/wm_tools.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.dart'; + +class WindowSize { + final double height; + final double width; + final bool maximized; + + WindowSize({ + required this.height, + required this.width, + required this.maximized, + }); + + factory WindowSize.fromJson(Map json) => WindowSize( + height: json["height"], + width: json["width"], + maximized: json["maximized"], + ); + + Map toJson() => { + "height": height, + "width": width, + "maximized": maximized, + }; +} + +class WindowManagerTools with WidgetsBindingObserver { + static WindowManagerTools? _instance; + static WindowManagerTools get instance => _instance!; + + WindowManagerTools._(); + + static Future initialize() async { + await windowManager.ensureInitialized(); + _instance = WindowManagerTools._(); + WidgetsBinding.instance.addObserver(instance); + + await windowManager.waitUntilReadyToShow( + const WindowOptions( + title: "Spotube", + backgroundColor: Colors.transparent, + minimumSize: Size(300, 700), + titleBarStyle: TitleBarStyle.hidden, + ), + () async { + final savedSize = KVStoreService.windowSize; + await windowManager.setResizable(true); + if (savedSize?.maximized == true && + !(await windowManager.isMaximized())) { + await windowManager.maximize(); + } else if (savedSize != null) { + await windowManager.setSize(Size(savedSize.width, savedSize.height)); + } + + await windowManager.focus(); + await windowManager.show(); + }, + ); + } + + Size? _prevSize; + + @override + void didChangeMetrics() async { + super.didChangeMetrics(); + if (kIsMobile) return; + final size = await windowManager.getSize(); + final windowSameDimension = + _prevSize?.width == size.width && _prevSize?.height == size.height; + + if (windowSameDimension || _prevSize == null) { + _prevSize = size; + return; + } + final isMaximized = await windowManager.isMaximized(); + await KVStoreService.setWindowSize( + WindowSize( + height: size.height, + width: size.width, + maximized: isMaximized, + ), + ); + _prevSize = size; + } +} diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 51e98269..28acc280 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -4,18 +4,32 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { final scheme = ColorScheme.fromSeed( seedColor: seed, shadow: Colors.black12, - background: isAmoled ? Colors.black : null, surface: isAmoled ? Colors.black : null, + surfaceContainer: isAmoled ? const Color(0xFF090909) : null, + surfaceContainerHigh: isAmoled ? const Color(0xFF181818) : null, + surfaceContainerHighest: isAmoled ? const Color(0xFF282828) : null, brightness: brightness, ); return ThemeData( useMaterial3: true, colorScheme: scheme, + scaffoldBackgroundColor: isAmoled ? Colors.black : null, + cardTheme: CardTheme( + color: scheme.surfaceContainer, + shadowColor: scheme.shadow, + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + ), listTileTheme: ListTileThemeData( horizontalTitleGap: 5, iconColor: scheme.onSurface, ), - appBarTheme: const AppBarTheme(surfaceTintColor: Colors.transparent), + appBarTheme: const AppBarTheme( + surfaceTintColor: Colors.transparent, + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + elevation: 0, + ), inputDecorationTheme: InputDecorationTheme( border: OutlineInputBorder( borderRadius: BorderRadius.circular(15), @@ -25,7 +39,7 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { navigationBarTheme: const NavigationBarThemeData( labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, height: 50, - iconTheme: MaterialStatePropertyAll( + iconTheme: WidgetStatePropertyAll( IconThemeData(size: 18), ), ), @@ -52,25 +66,25 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { ), sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay), searchBarTheme: SearchBarThemeData( - textStyle: const MaterialStatePropertyAll(TextStyle(fontSize: 15)), + textStyle: const WidgetStatePropertyAll(TextStyle(fontSize: 15)), constraints: const BoxConstraints(maxWidth: double.infinity), - padding: const MaterialStatePropertyAll(EdgeInsets.all(8)), - backgroundColor: MaterialStatePropertyAll( + padding: const WidgetStatePropertyAll(EdgeInsets.all(8)), + backgroundColor: WidgetStatePropertyAll( Color.lerp( - scheme.surfaceVariant, + scheme.surfaceContainerHighest, scheme.surface, brightness == Brightness.light ? .9 : .7, ), ), - elevation: const MaterialStatePropertyAll(0), - shape: MaterialStatePropertyAll( + elevation: const WidgetStatePropertyAll(0), + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), ), scrollbarTheme: const ScrollbarThemeData( - thickness: MaterialStatePropertyAll(14), + thickness: WidgetStatePropertyAll(14), ), checkboxTheme: CheckboxThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 88c52896..aa2cd985 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -1,19 +1,28 @@ -import 'dart:convert'; - -import 'package:flutter/widgets.dart' hide Element; +import 'package:dio/dio.dart'; import 'package:go_router/go_router.dart'; -import 'package:html/dom.dart'; +import 'package:html/dom.dart' hide Text; import 'package:spotify/spotify.dart'; import 'package:spotube/components/library/user_local_tracks.dart'; +import 'package:spotube/components/root/update_dialog.dart'; import 'package:spotube/models/logger.dart'; -import 'package:http/http.dart' as http; import 'package:spotube/models/lyrics.dart'; +import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:collection/collection.dart'; import 'package:html/parser.dart' as parser; +import 'dart:async'; + +import 'package:flutter/material.dart' hide Element; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:spotube/collections/env.dart'; + +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:version/version.dart'; + abstract class ServiceUtils { static final logger = getLogger("ServiceUtils"); @@ -60,9 +69,12 @@ abstract class ServiceUtils { } static Future extractLyrics(Uri url) async { - final response = await http.get(url); + final response = await globalDio.getUri( + url, + options: Options(responseType: ResponseType.plain), + ); - Document document = parser.parse(response.body); + Document document = parser.parse(response.data); String? lyrics = document.querySelector('div.lyrics')?.text.trim(); if (lyrics == null) { lyrics = ""; @@ -101,11 +113,14 @@ abstract class ServiceUtils { String reqUrl = "$searchUrl${Uri.encodeComponent(song)}"; Map headers = {"Authorization": 'Bearer $apiKey'}; - final response = await http.get( + final response = await globalDio.getUri( Uri.parse(authHeader ? reqUrl : "$reqUrl&access_token=$apiKey"), - headers: authHeader ? headers : null, + options: Options( + headers: authHeader ? headers : null, + responseType: ResponseType.json, + ), ); - Map data = jsonDecode(response.body)["response"]; + Map data = response.data["response"]; if (data["hits"]?.length == 0) return null; List results = data["hits"]?.map((val) { return { @@ -185,8 +200,11 @@ abstract class ServiceUtils { queryParameters: {"q": query}, ); - final res = await http.get(searchUri); - final document = parser.parse(res.body); + final res = await globalDio.getUri( + searchUri, + options: Options(responseType: ResponseType.plain), + ); + final document = parser.parse(res.data); final results = document.querySelectorAll("#tablecontainer table tbody tr td a"); @@ -219,7 +237,11 @@ abstract class ServiceUtils { logger.v("[Selected subtitle] ${topResult.text} | $subtitleUri"); - final lrcDocument = parser.parse((await http.get(subtitleUri)).body); + final lrcDocument = parser.parse((await globalDio.getUri( + subtitleUri, + options: Options(responseType: ResponseType.plain), + )) + .data); final lrcList = lrcDocument .querySelector("#ctl00_ContentPlaceHolder1_lbllyrics") ?.innerHtml @@ -262,6 +284,22 @@ abstract class ServiceUtils { GoRouter.of(context).go(location, extra: extra); } + static void navigateNamed( + BuildContext context, + String name, { + Object? extra, + Map? pathParameters, + Map? queryParameters, + }) { + if (GoRouterState.of(context).matchedLocation == name) return; + GoRouter.of(context).goNamed( + name, + pathParameters: pathParameters ?? const {}, + queryParameters: queryParameters ?? const {}, + extra: extra, + ); + } + static void push(BuildContext context, String location, {Object? extra}) { final router = GoRouter.of(context); final routerState = GoRouterState.of(context); @@ -273,6 +311,36 @@ abstract class ServiceUtils { router.push(location, extra: extra); } + static void pushNamed( + BuildContext context, + String name, { + Object? extra, + Map pathParameters = const {}, + Map queryParameters = const {}, + }) { + final router = GoRouter.of(context); + final routerState = GoRouterState.of(context); + final routerStack = router.routerDelegate.currentConfiguration.matches + .map((e) => e.matchedLocation); + + final nameLocation = routerState.namedLocation( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + ); + + if (routerState.matchedLocation == nameLocation || + routerStack.contains(nameLocation)) { + return; + } + router.pushNamed( + name, + pathParameters: pathParameters, + queryParameters: queryParameters, + extra: extra, + ); + } + static DateTime parseSpotifyAlbumDate(AlbumSimple? album) { if (album == null || album.releaseDate == null) { return DateTime.parse("1975-01-01"); @@ -318,4 +386,67 @@ abstract class ServiceUtils { } }); } + + static Future checkForUpdates( + BuildContext context, + WidgetRef ref, + ) async { + if (!Env.enableUpdateChecker) return; + if (!ref.read(userPreferencesProvider.select((s) => s.checkUpdate))) return; + final packageInfo = await PackageInfo.fromPlatform(); + + if (Env.releaseChannel == ReleaseChannel.nightly) { + final value = await globalDio.getUri( + Uri.parse( + "https://api.github.com/repos/KRTirtho/spotube/actions/workflows/spotube-release-binary.yml/runs?status=success&per_page=1", + ), + options: Options( + responseType: ResponseType.json, + ), + ); + + final buildNum = value.data["workflow_runs"][0]["run_number"] as int; + + if (buildNum <= int.parse(packageInfo.buildNumber) || !context.mounted) { + return; + } + + await showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.black26, + builder: (context) { + return RootAppUpdateDialog.nightly(nightlyBuildNum: buildNum); + }, + ); + } else { + final value = await globalDio.getUri( + Uri.parse( + "https://api.github.com/repos/KRTirtho/spotube/releases/latest", + ), + ); + final tagName = (value.data["tag_name"] as String).replaceAll("v", ""); + final currentVersion = packageInfo.version == "Unknown" + ? null + : Version.parse(packageInfo.version); + final latestVersion = + tagName == "nightly" ? null : Version.parse(tagName); + + if (currentVersion == null || + latestVersion == null || + (latestVersion.isPreRelease && !currentVersion.isPreRelease) || + (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return; + + if (latestVersion <= currentVersion || !context.mounted) return; + + showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.black26, + builder: (context) { + return RootAppUpdateDialog(version: latestVersion); + }, + ); + } + } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index c69c17c0..2f61edd6 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -47,6 +48,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) system_tray_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemTrayPlugin"); system_tray_plugin_register_with_registrar(system_tray_registrar); + g_autoptr(FlPluginRegistrar) tray_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); + tray_manager_plugin_register_with_registrar(tray_manager_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index a4487f4d..48c7e0ca 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST screen_retriever system_theme system_tray + tray_manager url_launcher_linux window_manager window_size diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a9f6650f..0057db14 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -22,6 +22,7 @@ import shared_preferences_foundation import sqflite import system_theme import system_tray +import tray_manager import url_launcher_macos import window_manager import window_size @@ -37,13 +38,14 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) - FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) + TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) WindowSizePlugin.register(with: registry.registrar(forPlugin: "WindowSizePlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 317de385..166bfa71 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -18,9 +18,6 @@ PODS: - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) - - FMDB (2.7.5): - - FMDB/standard (= 2.7.5) - - FMDB/standard (2.7.5) - local_notifier (0.1.0): - FlutterMacOS - media_kit_libs_macos_audio (1.0.4): @@ -39,13 +36,15 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.2): + - sqflite (0.0.3): + - Flutter - FlutterMacOS - - FMDB (>= 2.7.5) - system_theme (0.0.1): - FlutterMacOS - system_tray (0.0.1): - FlutterMacOS + - tray_manager (0.0.1): + - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS - window_manager (0.2.0): @@ -71,16 +70,16 @@ DEPENDENCIES: - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) - system_tray (from `Flutter/ephemeral/.symlinks/plugins/system_tray/macos`) + - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) SPEC REPOS: trunk: - - FMDB - OrderedSet EXTERNAL SOURCES: @@ -119,11 +118,13 @@ EXTERNAL SOURCES: shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin system_theme: :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos system_tray: :path: Flutter/ephemeral/.symlinks/plugins/system_tray/macos + tray_manager: + :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos window_manager: @@ -132,28 +133,28 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: - app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 + app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 - device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f + device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c - package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce - path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c + path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 - sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea + shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc system_tray: e53c972838c69589ff2e77d6d3abfd71332f9e5d + tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 diff --git a/pubspec.lock b/pubspec.lock index 8d19f604..cf72db1c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" url: "https://pub.dev" source: hosted - version: "61.0.0" + version: "67.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" url: "https://pub.dev" source: hosted - version: "5.13.0" + version: "6.4.1" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d + sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" url: "https://pub.dev" source: hosted - version: "0.11.2" + version: "0.11.3" ansicolor: dependency: transitive description: @@ -37,90 +37,26 @@ packages: dependency: "direct main" description: name: app_links - sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" + sha256: "42dc15aecf2618ace4ffb74a2e58a50e45cd1b9f2c17c8f0cafe4c297f08c815" url: "https://pub.dev" source: hosted - version: "3.5.0" - app_package_maker: - dependency: transitive - description: - name: app_package_maker - sha256: "0dc1949e09a60ec2d0b79b43c5237734e6d03f7a022919bdfe1b4789f4c3bfb0" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_aab: - dependency: transitive - description: - name: app_package_maker_aab - sha256: "44810e77dff3b3b54011270b01a42876e838766d6e85c98f9a33bfe61db51651" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_apk: - dependency: transitive - description: - name: app_package_maker_apk - sha256: "974e639cda26c2e18fffaba18f88797523731f5b5457056b25e92243c9191f61" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_deb: - dependency: transitive - description: - name: app_package_maker_deb - sha256: dcd4047cb67648e53afd61079a8baa3c8ea383668f068e3ce8da841f3728eb29 - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_dmg: - dependency: transitive - description: - name: app_package_maker_dmg - sha256: e0410a51304f3fff3e3850696c8e56f53f71c990e097f1c325126ebe90d242c4 - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_exe: - dependency: transitive - description: - name: app_package_maker_exe - sha256: "07e3899a3ae12e8b6cd80efc7281ccca6c9050d2810e0fdc0e7e614cf4bd8a02" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_ipa: - dependency: transitive - description: - name: app_package_maker_ipa - sha256: "1a11498506ba975d02a4715650701981a382a2161c81481911517b50b378cd65" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - app_package_maker_zip: - dependency: transitive - description: - name: app_package_maker_zip - sha256: cef07a47c589036a4762fdc9e61b9022f0a2a2a9f69538109a0a952a7e668306 - url: "https://pub.dev" - source: hosted - version: "0.0.9" + version: "4.0.1" archive: dependency: transitive description: name: archive - sha256: ca12e6c9ac022f33fd89128e7007fb5e97ab6e814d4fa05dd8d4f2db1e3c69cb + sha256: ecf4273855368121b1caed0d10d4513c7241dfc813f7d3c8933b36622ae9b265 url: "https://pub.dev" source: hosted - version: "3.4.5" + version: "3.5.1" args: dependency: "direct main" description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.5.0" async: dependency: "direct main" description: @@ -133,18 +69,18 @@ packages: dependency: "direct main" description: name: audio_service - sha256: a4d989f1225ea9621898d60f23236dcbfc04876fa316086c23c5c4af075dbac4 + sha256: "4547c312a94f9cb2c48b60823fb190767cbd63454a83c73049384d5d3cba4650" url: "https://pub.dev" source: hosted - version: "0.18.12" + version: "0.18.13" audio_service_mpris: dependency: "direct main" description: name: audio_service_mpris - sha256: "31be5de2db0c71b217157afce1974ac6d0ad329bd91deb1f19ad094d29340d8e" + sha256: a8d1583f9143d17b2facc994a99bd1ea257cec43adcb8d7349458555c62b570f url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.3" audio_service_platform_interface: dependency: transitive description: @@ -157,18 +93,18 @@ packages: dependency: transitive description: name: audio_service_web - sha256: "523e64ddc914c714d53eec2da85bba1074f08cf26c786d4efb322de510815ea7" + sha256: "9d7d5ae5f98a5727f2580fad73062f2484f400eef6cef42919413268e62a363e" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.2" audio_session: dependency: "direct main" description: name: audio_session - sha256: "6fdf255ed3af86535c96452c33ecff1245990bb25a605bfb1958661ccc3d467f" + sha256: a49af9981eec5d7cd73b37bacb6ee73f8143a6a9f9bd5b6021e6c346b9b6cf4e url: "https://pub.dev" source: hosted - version: "0.1.18" + version: "0.1.19" auto_size_text: dependency: "direct main" description: @@ -261,18 +197,18 @@ packages: dependency: transitive description: name: build_daemon - sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" + sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.1" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: d912852cce27c9e80a93603db721c267716894462e7033165178b91138587972 + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.4.2" build_runner: dependency: "direct dev" description: @@ -285,10 +221,10 @@ packages: dependency: transitive description: name: build_runner_core - sha256: "6d6ee4276b1c5f34f21fdf39425202712d2be82019983d52f351c94aafbc2c41" + sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" url: "https://pub.dev" source: hosted - version: "7.2.10" + version: "7.3.0" built_collection: dependency: transitive description: @@ -301,18 +237,18 @@ packages: dependency: transitive description: name: built_value - sha256: ff627b645b28fb8bdb69e645f910c2458fd6b65f6585c3a53e0626024897dedf + sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb url: "https://pub.dev" source: hosted - version: "8.6.2" + version: "8.9.2" buttons_tabbar: dependency: "direct main" description: name: buttons_tabbar - sha256: "781128180f3e76cf93c093183f10395c664983dbee20bc4da2025be70085c2da" + sha256: "3f0969c26574ef15c0c9ff1dee42c3c4b0d3563d2c8607804372490fb8b76896" url: "https://pub.dev" source: hosted - version: "1.3.7+1" + version: "1.3.8" cached_network_image: dependency: "direct main" description: @@ -341,10 +277,10 @@ packages: dependency: "direct main" description: name: catcher_2 - sha256: "73e251057b1b59442d58b5109d26401cfed850df21da44b028338d4f85f69105" + sha256: "3c8f6cedc8c5eab61192830096d4f303900a5d0bddbf96a07ff9f7a8d5ff8fcd" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.2.4" change_case: dependency: transitive description: @@ -381,10 +317,10 @@ packages: dependency: transitive description: name: cli_util - sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 + sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.1" clock: dependency: transitive description: @@ -397,10 +333,10 @@ packages: dependency: transitive description: name: code_builder - sha256: "315a598c7fbe77f22de1c9da7cfd6fd21816312f16ffa124453b4fc679e540f1" + sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 url: "https://pub.dev" source: hosted - version: "4.6.0" + version: "4.10.0" collection: dependency: "direct main" description: @@ -429,10 +365,10 @@ packages: dependency: transitive description: name: cross_file - sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb + sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" url: "https://pub.dev" source: hosted - version: "0.3.3+5" + version: "0.3.4+1" crypto: dependency: "direct main" description: @@ -449,14 +385,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d - url: "https://pub.dev" - source: hosted - version: "1.0.6" curved_navigation_bar: dependency: "direct main" description: @@ -469,26 +397,26 @@ packages: dependency: "direct dev" description: name: custom_lint - sha256: "22bd87a362f433ba6aae127a7bac2838645270737f3721b180916d7c5946cb5d" + sha256: "7c0aec12df22f9082146c354692056677f1e70bc43471644d1fdb36c6fdda799" url: "https://pub.dev" source: hosted - version: "0.5.11" + version: "0.6.4" custom_lint_builder: dependency: transitive description: name: custom_lint_builder - sha256: "0d48e002438950f9582e574ef806b2bea5719d8d14c0f9f754fbad729bcf3b19" + sha256: d7dc41e709dde223806660268678be7993559e523eb3164e2a1425fd6f7615a9 url: "https://pub.dev" source: hosted - version: "0.5.14" + version: "0.6.4" custom_lint_core: dependency: transitive description: name: custom_lint_core - sha256: "2952837953022de610dacb464f045594854ced6506ac7f76af28d4a6490e189b" + sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 url: "https://pub.dev" source: hosted - version: "0.5.14" + version: "0.6.3" dart_des: dependency: transitive description: @@ -506,14 +434,22 @@ packages: url: "https://github.com/Tommypop2/dart_discord_rpc.git" source: git version: "0.0.3" + dart_mappable: + dependency: transitive + description: + name: dart_mappable + sha256: "47269caf2060533c29b823ff7fa9706502355ffcb61e7f2a374e3a0fb2f2c3f0" + url: "https://pub.dev" + source: hosted + version: "4.2.2" dart_style: dependency: transitive description: name: dart_style - sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.6" dartx: dependency: transitive description: @@ -530,22 +466,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" - device_frame: - dependency: transitive - description: - name: device_frame - sha256: afe76182aec178d171953d9b4a50a43c57c7cf3c77d8b09a48bf30c8fa04dd9d - url: "https://pub.dev" - source: hosted - version: "1.1.0" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" + sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91 url: "https://pub.dev" source: hosted - version: "9.1.2" + version: "10.1.0" device_info_plus_platform_interface: dependency: transitive description: @@ -554,30 +482,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" - device_preview: - dependency: "direct main" - description: - name: device_preview - sha256: "2f097bf31b929e15e6756dbe0ec1bcb63952ab9ed51c25dc5a2c722d2b21fdaf" - url: "https://pub.dev" - source: hosted - version: "1.1.0" dio: dependency: "direct main" description: name: dio - sha256: "49af28382aefc53562459104f64d16b9dfd1e8ef68c862d5af436cc8356ce5a8" + sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5" url: "https://pub.dev" source: hosted - version: "5.4.1" + version: "5.4.3+1" disable_battery_optimization: dependency: "direct main" description: name: disable_battery_optimization - sha256: b3441975ab2a3ab0c19ed78e909a88d245ce689d43d17f9b23582b1ed41c047b + sha256: "6b2ba802f984af141faf1b6b5fb956d5ef01f9cd555597c35b9cc335a03185ba" url: "https://pub.dev" source: hosted - version: "1.1.0+1" + version: "1.1.1" dots_indicator: dependency: transitive description: @@ -607,18 +527,26 @@ packages: dependency: "direct main" description: name: envied - sha256: "60d3f5606c7b35bc6ef493e650d916b34351d8af2e58b7ac45881ba59dfcf039" + sha256: bbff9c76120e4dc5e2e36a46690cf0a26feb65e7765633f4e8d916bcd173a450 url: "https://pub.dev" source: hosted - version: "0.3.0+3" + version: "0.5.4+1" envied_generator: dependency: "direct dev" description: name: envied_generator - sha256: dfdbe5dc52863e54c036a4c4042afbdf1bd528cb4c1e638ecba26228ba72e9e5 + sha256: "517b70de08d13dcd40e97b4e5347e216a0b1c75c99e704f3c85c0474a392d14a" url: "https://pub.dev" source: hosted - version: "0.3.0+3" + version: "0.5.4+1" + equatable: + dependency: transitive + description: + name: equatable + sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 + url: "https://pub.dev" + source: hosted + version: "2.0.5" fake_async: dependency: transitive description: @@ -631,10 +559,10 @@ packages: dependency: transitive description: name: ffi - sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.2" file: dependency: transitive description: @@ -647,34 +575,34 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" + sha256: d1d0ac3966b36dc3e66eeefb40280c17feb87fa2099c6e22e6a1fc959327bd03 url: "https://pub.dev" source: hosted - version: "6.1.1" + version: "8.0.0+1" file_selector: dependency: "direct main" description: name: file_selector - sha256: "84eaf3e034d647859167d1f01cfe7b6352488f34c1b4932635012b202014c25b" + sha256: "5019692b593455127794d5718304ff1ae15447dea286cdda9f0db2a796a1b828" url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.3" file_selector_android: dependency: transitive description: name: file_selector_android - sha256: d41e165d6f798ca941d536e5dc93494d50e78c571c28ad60cfe0b0fefeb9f1e7 + sha256: "1cd66575f063b689e041aec836905ba7be18d76c9f0634d0d75daec825f67095" url: "https://pub.dev" source: hosted - version: "0.5.0+3" + version: "0.5.0+7" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: b3fbdda64aa2e335df6e111f6b0f1bb968402ed81d2dd1fa4274267999aa32c2 + sha256: "0a1196a9c5795858aa315332da2fb5c4bcfdcb312d8a4e27651f765b87904431" url: "https://pub.dev" source: hosted - version: "0.5.1+6" + version: "0.5.1+9" file_selector_linux: dependency: transitive description: @@ -687,26 +615,26 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: "182c3f8350cee659f7b115e956047ee3dc672a96665883a545e81581b9a82c72" + sha256: b15c3da8bd4908b9918111fa486903f5808e388b8d1c559949f584725a6594d6 url: "https://pub.dev" source: hosted - version: "0.9.3+2" + version: "0.9.3+3" file_selector_platform_interface: dependency: transitive description: name: file_selector_platform_interface - sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262" + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "2.6.2" file_selector_web: dependency: transitive description: name: file_selector_web - sha256: dc6622c4d66cb1bee623ddcc029036603c6cc45c85e4a775bb06008d61c809c1 + sha256: "619e431b224711a3869e30dbd7d516f5f5a4f04b265013a50912f39e1abc88c8" url: "https://pub.dev" source: hosted - version: "0.9.2+1" + version: "0.9.4+1" file_selector_windows: dependency: transitive description: @@ -727,31 +655,15 @@ packages: dependency: "direct main" description: name: fluentui_system_icons - sha256: "7637cab80bd8d1ba762144cd85df79a7318c12ed5a66d166a9e4acbf24a4c412" + sha256: "1c860f10a0e74c5788ff8a650ae6074d9a544463ae269714f1044b32df52b978" url: "https://pub.dev" source: hosted - version: "1.1.214" + version: "1.1.234" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" - flutter_app_builder: - dependency: transitive - description: - name: flutter_app_builder - sha256: "9e5527919f62424f0fafaa3e8dfda8469caf63e465862e9866a0d60a37c00fcf" - url: "https://pub.dev" - source: hosted - version: "0.0.9" - flutter_app_packager: - dependency: transitive - description: - name: flutter_app_packager - sha256: b5bfb7113b49710c004c5f1ab6f08ac121418540d49e14825dd75e99810fa695 - url: "https://pub.dev" - source: hosted - version: "0.0.9" flutter_broadcasts: dependency: "direct main" description: @@ -768,15 +680,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" - flutter_desktop_tools: - dependency: "direct main" - description: - path: "." - ref: "1f0bec3283626dcbd8ee2f54e238d096d8dea50e" - resolved-ref: "1f0bec3283626dcbd8ee2f54e238d096d8dea50e" - url: "https://github.com/KRTirtho/flutter_desktop_tools.git" - source: git - version: "0.0.1" flutter_displaymode: dependency: "direct main" description: @@ -785,14 +688,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" - flutter_distributor: - dependency: "direct dev" - description: - name: flutter_distributor - sha256: "50d56df265e97396427ec42cc02374b72d08c71b3442d662b97fc089bd1705ea" - url: "https://pub.dev" - source: hosted - version: "0.0.2" flutter_driver: dependency: transitive description: flutter @@ -890,10 +785,10 @@ packages: dependency: transitive description: name: flutter_keyboard_visibility - sha256: "4983655c26ab5b959252ee204c2fffa4afeb4413cd030455194ec0caa3b8e7cb" + sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8" url: "https://pub.dev" source: hosted - version: "5.4.1" + version: "6.0.0" flutter_keyboard_visibility_linux: dependency: transitive description: @@ -946,10 +841,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 + sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" flutter_localizations: dependency: "direct main" description: flutter @@ -959,42 +854,42 @@ packages: dependency: transitive description: name: flutter_mailer - sha256: a935e9caa842877e8ed56109afb75b86e6488edbcd4696a5ac02b327a48fcd8a + sha256: "4fffaa35e911ff5ec2e5a4ebbca62c372e99a154eb3bb2c0bf79f09adf6ecf4c" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" flutter_native_splash: dependency: "direct main" description: name: flutter_native_splash - sha256: "558f10070f03ee71f850a78f7136ab239a67636a294a44a06b6b7345178edb1e" + sha256: edf39bcf4d74aca1eb2c1e43c3e445fd9f494013df7f0da752fefe72020eedc0 url: "https://pub.dev" source: hosted - version: "2.3.10" + version: "2.4.0" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c + sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f" url: "https://pub.dev" source: hosted - version: "2.0.16" + version: "2.0.19" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: "4bce556b7ecbfea26109638d5237684538d4abc509d253e6c5c4c5733b360098" + sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d" url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.5.1" flutter_rust_bridge: dependency: transitive description: name: flutter_rust_bridge - sha256: e12415c3bce49bcbc3fed383f0ea41ad7d828f6cf0eccba0588ffa5a812fe522 + sha256: "02720226035257ad0b571c1256f43df3e1556a499f6bcb004849a0faaa0e87f0" url: "https://pub.dev" source: hosted - version: "1.82.1" + version: "1.82.6" flutter_secure_storage: dependency: "direct main" description: @@ -1047,10 +942,10 @@ packages: dependency: "direct main" description: name: flutter_sharing_intent - sha256: "6eb896e6523b735e8230eeb206fd3b9f220f11ce879c2400a90b443147036ff9" + sha256: "785ffc391822641457f930eb477c91c2f598a888f50b8fbb40d481ee01c7e719" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.1" flutter_svg: dependency: "direct main" description: @@ -1073,10 +968,10 @@ packages: dependency: transitive description: name: fluttertoast - sha256: "474f7d506230897a3cd28c965ec21c5328ae5605fc9c400cd330e9e9d6ac175c" + sha256: "81b68579e23fcbcada2db3d50302813d2371664afe6165bc78148050ab94bf66" url: "https://pub.dev" source: hosted - version: "8.2.2" + version: "8.2.5" form_validator: dependency: "direct main" description: @@ -1089,10 +984,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: "6c5031daae12c7072b3a87eff98983076434b4889ef2a44384d0cae3f82372ba" + sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1 url: "https://pub.dev" source: hosted - version: "2.4.6" + version: "2.5.2" freezed_annotation: dependency: "direct main" description: @@ -1105,10 +1000,10 @@ packages: dependency: transitive description: name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "4.0.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -1150,10 +1045,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: f0b8d115a13ecf827013ec9fc883390ccc0e87a96ed5347a3114cac177ef18e8 + sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.2.1" graphs: dependency: transitive description: @@ -1206,10 +1101,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: "758b07eba336e3cbacbd81dba481f2228a14102083fdde07045e8514e8054c49" + sha256: "45b2030a18bcd6dbd680c2c91bc3b33e3fe7c323e3acb5ecec93a613e2fbaa8a" url: "https://pub.dev" source: hosted - version: "2.4.10" + version: "2.5.1" hotreloader: dependency: transitive description: @@ -1270,42 +1165,42 @@ packages: dependency: transitive description: name: image - sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" + sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.1.7" image_picker: dependency: "direct main" description: name: image_picker - sha256: "7d7f2768df2a8b0a3cefa5ef4f84636121987d403130e70b17ef7e2cf650ba84" + sha256: fe9ee64ccb8d599a5dfb0e21cc6652232c610bcf667af4e79b9eb175cc30a7a5 url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "47da2161c2e9f8f8a9cbbd89d466d174333fbdd769aeed848912e0b16d9cb369" + sha256: "8e75431a62b7feb4fd55cb4a5c6f0ac4564460ec5dc09f9c4a0d50a5ce7c4cb9" url: "https://pub.dev" source: hosted - version: "0.8.8" + version: "0.8.10" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "50bc9ae6a77eea3a8b11af5eb6c661eeb858fdd2f734c2a4fd17086922347ef7" + sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.4" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: c5538cacefacac733c724be7484377923b476216ad1ead35a0d2eadcdc0fc497 + sha256: f4a6f62be96d6fd268f32a6bf8ef444cd8e3fff64d16923c6e6fe55e0c84a761 url: "https://pub.dev" source: hosted - version: "0.8.8+2" + version: "0.8.10" image_picker_linux: dependency: transitive description: @@ -1326,10 +1221,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: ed9b00e63977c93b0d2d2b343685bed9c324534ba5abafbb3dfbd6a780b1b514 + sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" url: "https://pub.dev" source: hosted - version: "2.9.1" + version: "2.10.0" image_picker_windows: dependency: transitive description: @@ -1347,20 +1242,20 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.19.0" introduction_screen: dependency: "direct main" description: name: introduction_screen - sha256: ef5a5479a8e06a84b9a7eff16c698b9b82f70cd1b6203b264bc3686f9bfb77e2 + sha256: "325f26e86fa3c3e86e6ab2bbc1fda860c9e6eae5ff29166fc2a3cab8f710d5b5" url: "https://pub.dev" source: hosted - version: "3.1.11" + version: "3.1.14" io: - dependency: transitive + dependency: "direct dev" description: name: io sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" @@ -1403,26 +1298,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -1432,21 +1327,21 @@ packages: source: hosted version: "3.0.0" local_notifier: - dependency: transitive + dependency: "direct main" description: name: local_notifier - sha256: cc855aa6362c8840e3d3b35b1c3b058a3a8becdb2b03d5a9aa3f3a1e861f0a03 + sha256: f6cfc933c6fbc961f4e52b5c880f68e41b2d3cd29aad557cc654fd211093a025 url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.1.6" logger: dependency: "direct main" description: name: logger - sha256: ba3bc83117b2b49bdd723c0ea7848e8285a0fbc597ba09203b20d329d020c24a + sha256: "8c94b8c219e7e50194efc8771cd0e9f10807d8d3e219af473d89b06cc2ee4e04" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.2.0" logging: dependency: transitive description: @@ -1467,10 +1362,10 @@ packages: dependency: transitive description: name: mailer - sha256: "57f6dd1496699999a7bfd0aa6be0645384f477f4823e16d4321c40a434346382" + sha256: d25d89555c1031abacb448f07b801d7c01b4c21d4558e944b12b64394c84a3cb url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.1.0" matcher: dependency: transitive description: @@ -1491,26 +1386,26 @@ packages: dependency: "direct main" description: name: media_kit - sha256: "92c7f59e075d74471b31e703f81ccc1d7102739ebcce945b30a6417fa2f751d5" + sha256: "3289062540e3b8b9746e5c50d95bd78a9289826b7227e253dff806d002b9e67a" url: "https://pub.dev" source: hosted - version: "1.1.7" + version: "1.1.10+1" media_kit_libs_android_audio: dependency: transitive description: name: media_kit_libs_android_audio - sha256: "1d9ba8814e58a12ae69014884abf96893d38e444abfb806ff38896dfe089dd15" + sha256: "3d2df5c09d3f3ff7c55b53bf955e46712f76483e77562a5a017439a3ea85ce88" url: "https://pub.dev" source: hosted - version: "1.3.5" + version: "1.3.6" media_kit_libs_audio: dependency: "direct main" description: name: media_kit_libs_audio - sha256: "9ccf9019edc220b8d0bfa1f53a91aa2cf2506ac860b4d5774c9d6bbdd49796ca" + sha256: f3f91df69848005363b3ae0ef7971a90edbd80a9365195684ef26c9a6ac8833f url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" media_kit_libs_ios_audio: dependency: transitive description: @@ -1551,14 +1446,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + menu_base: + dependency: transitive + description: + name: menu_base + sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" + url: "https://pub.dev" + source: hosted + version: "0.1.1" meta: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" metadata_god: dependency: "direct main" description: @@ -1571,18 +1474,10 @@ packages: dependency: "direct main" description: name: mime - sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" url: "https://pub.dev" source: hosted - version: "1.0.4" - nested: - dependency: transitive - description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + version: "1.0.5" oauth2: dependency: transitive description: @@ -1611,10 +1506,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: "6ff267fcd9d48cb61c8df74a82680e8b82e940231bb5f68356672fde0397334a" + sha256: cb44f49b6e690fa766f023d5b22cac6b9affe741dd792b6ac7ad4fabe0d7b097 url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "6.0.0" package_info_plus_platform_interface: dependency: transitive description: @@ -1659,26 +1554,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.3" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" + sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.4" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" path_provider_linux: dependency: transitive description: @@ -1691,10 +1586,10 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" path_provider_windows: dependency: transitive description: @@ -1707,42 +1602,50 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" + sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" url: "https://pub.dev" source: hosted - version: "11.0.1" + version: "11.3.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: ace7d15a3d1a4a0b91c041d01e5405df221edb9de9116525efc773c74e6fc790 + sha256: "1acac6bae58144b442f11e66621c062aead9c99841093c38f5bcdcc24c1c3474" url: "https://pub.dev" source: hosted - version: "11.0.5" + version: "12.0.5" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" + sha256: e9ad66020b89ff1b63908f247c2c6f931c6e62699b756ef8b3c4569350cd8662 url: "https://pub.dev" source: hosted - version: "9.1.4" + version: "9.4.4" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: f2343e9fa9c22ae4fd92d4732755bfe452214e7189afcc097380950cf567b4b2 + sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" url: "https://pub.dev" source: hosted - version: "3.11.5" + version: "4.2.1" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.2.1" petitparser: dependency: transitive description: @@ -1754,11 +1657,10 @@ packages: piped_client: dependency: "direct main" description: - path: "." - ref: HEAD - resolved-ref: "64631732eefe3d93889756dc2e4ff5c8523ed763" - url: "https://github.com/KRTirtho/piped_client.git" - source: git + name: piped_client + sha256: "87b04b2ebf4e008cfbb0ac85e9920ab3741f5aa697be2dd44919658a3297a4bc" + url: "https://pub.dev" + source: hosted version: "0.1.1" platform: dependency: transitive @@ -1772,18 +1674,10 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.6" - pointycastle: - dependency: transitive - description: - name: pointycastle - sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" - url: "https://pub.dev" - source: hosted - version: "3.7.3" + version: "2.1.8" pool: dependency: transitive description: @@ -1808,22 +1702,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" - provider: - dependency: transitive + process_run: + dependency: "direct dev" description: - name: provider - sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f + name: process_run + sha256: "8d9c6198b98fbbfb511edd42e7364e24d85c163e47398919871b952dc86a423e" url: "https://pub.dev" source: hosted - version: "6.0.5" + version: "0.14.2" pub_api_client: dependency: "direct main" description: name: pub_api_client - sha256: d456816ef5142906a22dc56e37be6bef6cb0276f0a26c11d1f7d277868202e71 + sha256: cc3d2c93df3823553de6a3e7d3ac09a3f43f8c271af4f43c2795266090ac9625 url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" pub_semver: dependency: transitive description: @@ -1852,10 +1746,10 @@ packages: dependency: transitive description: name: puppeteer - sha256: eedeaae6ec5d2e54f9ae22ab4d6b3dda2e8791c356cc783046d06c287ffe11d8 + sha256: "6833edca01b1e9dcdd9a6e41bad84b706dfba4366d095c4edff64b00c02ac472" url: "https://pub.dev" source: hosted - version: "3.6.0" + version: "3.8.0" quiver: dependency: transitive description: @@ -1864,30 +1758,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" riverpod: dependency: transitive description: name: riverpod - sha256: "548e2192eb7aeb826eb89387f814edb76594f3363e2c0bb99dd733d795ba3589" + sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.1" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: d72d7096964baf288b55619fe48100001fc4564ab7923ed0a7f5c7650e03c0d6 + sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f" url: "https://pub.dev" source: hosted - version: "0.3.4" + version: "0.5.1" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "70198738c3047ae4f6517ef1a2011a8514a980a52576c7f629a3a08810319a02" + sha256: "3c67c14ccd16f0c9d53e35ef70d06cd9d072e2fb14557326886bbde903b230a5" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.3.10" rxdart: dependency: transitive description: @@ -1933,34 +1835,34 @@ packages: dependency: transitive description: name: sentry - sha256: "39c23342fc96105da449914f7774139a17a0ca8a4e70d9ad5200171f7e47d6ba" + sha256: "19a267774906ca3a3c4677fc7e9582ea9da79ae9a28f84bbe4885dac2c269b70" url: "https://pub.dev" source: hosted - version: "7.9.0" + version: "7.20.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" + sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "2.3.5" shared_preferences_linux: dependency: transitive description: @@ -1973,18 +1875,18 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" shared_preferences_windows: dependency: transitive description: @@ -2025,22 +1927,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + shortid: + dependency: transitive + description: + name: shortid + sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb + url: "https://pub.dev" + source: hosted + version: "0.1.2" sidebarx: dependency: "direct main" description: name: sidebarx - sha256: "7042d64844b8e64ca5c17e70d89b49df35b54a26c015b90000da9741eab70bc0" + sha256: abe39d6db237fb8e25c600e8039ffab80fa7fe71acab03e9c378c31f912d2766 url: "https://pub.dev" source: hosted - version: "0.16.3" + version: "0.17.1" simple_icons: dependency: "direct main" description: name: simple_icons - sha256: "8aa6832dc7a263a3213e40ecbf1328a392308c809d534a3b860693625890483b" + sha256: "30067d70a9d72923fbc80e142e17fa46085dfa970e66bc4bede3be4819d05901" url: "https://pub.dev" source: hosted - version: "7.10.0" + version: "10.1.3" skeleton_text: dependency: "direct main" description: @@ -2053,10 +1963,10 @@ packages: dependency: "direct main" description: name: skeletonizer - sha256: ff4c36e826efd5288d7a84e7619a6e9be8185d3064cecf101a9133762f3b401b + sha256: "9a3ae2f4ee4349bdbed3292d04586a1315a44745d2c454684f82f0c46dbeabf9" url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "1.1.1" sky_engine: dependency: transitive description: flutter @@ -2074,18 +1984,18 @@ packages: dependency: "direct main" description: name: smtc_windows - sha256: aba2bad5ddfaf595496db04df3d9fdb54fb128fc1f39c8f024945a67455388fe + sha256: "799bbe0f8e4436da852c5dcc0be482c97b8ae0f504f65c6b750cd239b4835aa0" url: "https://pub.dev" source: hosted - version: "0.1.1" + version: "0.1.2" source_gen: dependency: transitive description: name: source_gen - sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.5.0" source_helper: dependency: transitive description: @@ -2105,27 +2015,36 @@ packages: spotify: dependency: "direct main" description: - name: spotify - sha256: "2308a84511c18ec1e72515a57e28abb1467389549d571c460732b4538c2e34de" + path: "." + ref: "fix/explicit-to-json" + resolved-ref: c4b37c599413ac7bfd78993e416a56105c62b634 + url: "https://github.com/KRTirtho/spotify-dart.git" + source: git + version: "0.13.6" + sprintf: + dependency: transitive + description: + name: sprintf + sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" url: "https://pub.dev" source: hosted - version: "0.13.3" + version: "7.0.0" sqflite: dependency: transitive description: name: sqflite - sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" + sha256: "5ce2e1a15e822c3b4bfb5400455775e421da7098eed8adc8f26298ada7c9308c" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.3" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" + sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.5.4" stack_trace: dependency: transitive description: @@ -2138,10 +2057,10 @@ packages: dependency: transitive description: name: state_notifier - sha256: "8fe42610f179b843b12371e40db58c9444f8757f8b69d181c97e50787caed289" + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb url: "https://pub.dev" source: hosted - version: "0.7.2+1" + version: "1.0.0" stream_channel: dependency: transitive description: @@ -2186,10 +2105,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.0+1" system_theme: dependency: "direct main" description: @@ -2209,10 +2128,11 @@ packages: system_tray: dependency: "direct overridden" description: - name: system_tray - sha256: "087edb877f22f286d82d42f330fa640138c192e98aa9d20c2b83aa4e406bb432" - url: "https://pub.dev" - source: hosted + path: "." + ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c + resolved-ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c + url: "https://github.com/antler119/system_tray" + source: git version: "2.0.2" term_glyph: dependency: transitive @@ -2226,18 +2146,18 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" time: dependency: transitive description: name: time - sha256: "83427e11d9072e038364a5e4da559e85869b227cf699a541be0da74f14140124" + sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" timezone: dependency: "direct main" description: @@ -2262,6 +2182,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + tray_manager: + dependency: "direct main" + description: + name: tray_manager + sha256: e0ac9a88b2700f366b8629b97e8663b6ef450a2f169560a685dc167bfe9c9c29 + url: "https://pub.dev" + source: hosted + version: "0.2.2" tuple: dependency: transitive description: @@ -2270,6 +2198,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + type_plus: + dependency: transitive + description: + name: type_plus + sha256: d5d1019471f0d38b91603adb9b5fd4ce7ab903c879d2fbf1a3f80a630a03fcc9 + url: "https://pub.dev" + source: hosted + version: "2.1.1" typed_data: dependency: transitive description: @@ -2314,74 +2250,74 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" + sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" url: "https://pub.dev" source: hosted - version: "6.1.14" + version: "6.2.6" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 + sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.3.1" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" + sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "6.2.5" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.1.1" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 + sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "3.1.0" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.3.2" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" url: "https://pub.dev" source: hosted - version: "2.0.20" + version: "2.3.1" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 url: "https://pub.dev" source: hosted - version: "3.0.8" + version: "3.1.1" uuid: dependency: "direct main" description: name: uuid - sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" + sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "4.4.0" vector_math: dependency: transitive description: @@ -2418,10 +2354,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" watcher: dependency: transitive description: @@ -2434,18 +2370,18 @@ packages: dependency: transitive description: name: web - sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad" + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" url: "https://pub.dev" source: hosted - version: "0.5.0" + version: "0.5.1" web_socket_channel: dependency: "direct main" description: name: web_socket_channel - sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" url: "https://pub.dev" source: hosted - version: "2.4.4" + version: "2.4.5" webdriver: dependency: transitive description: @@ -2466,26 +2402,26 @@ packages: dependency: transitive description: name: win32 - sha256: "9e82a402b7f3d518fb9c02d0e9ae45952df31b9bf34d77baf19da2de03fc2aaa" + sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" url: "https://pub.dev" source: hosted - version: "5.0.7" + version: "5.4.0" win32_registry: dependency: "direct main" description: name: win32_registry - sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" + sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" window_manager: dependency: "direct main" description: name: window_manager - sha256: "6ee795be9124f90660ea9d05e581a466de19e1c89ee74fc4bf528f60c8600edd" + sha256: "8699323b30da4cdbe2aa2e7c9de567a6abd8a97d9a5c850a3c86dcd0b34bbfbf" url: "https://pub.dev" source: hosted - version: "0.3.6" + version: "0.3.9" window_size: dependency: "direct main" description: @@ -2499,12 +2435,12 @@ packages: dependency: transitive description: name: xdg_directories - sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4" xml: - dependency: transitive + dependency: "direct dev" description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 @@ -2523,10 +2459,10 @@ packages: dependency: "direct main" description: name: youtube_explode_dart - sha256: "98fd11b51adbbca76cbdb17f560168f1d7a9835cecceea965f49eb1e5eed155c" + sha256: "26c9671d638f3396a1bfb2666f586988ee7b0ba3469e478b22a4c1a168bcf6ee" url: "https://pub.dev" source: hosted - version: "2.0.2" + version: "2.2.1" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.16.0" + flutter: ">=3.19.2" diff --git a/pubspec.yaml b/pubspec.yaml index 3f4c22af..c256f66e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.6.0+30 +version: 3.7.0+31 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube @@ -13,96 +13,87 @@ environment: flutter: ">=3.10.0" dependencies: - args: ^2.3.2 + args: ^2.5.0 async: ^2.9.0 - audio_service: ^0.18.9 - audio_session: ^0.1.18 + audio_service: ^0.18.13 + audio_service_mpris: ^0.1.3 + audio_session: ^0.1.19 auto_size_text: ^3.0.0 - buttons_tabbar: ^1.3.6 + buttons_tabbar: ^1.3.8 cached_network_image: ^3.3.1 - catcher_2: 1.0.0 + catcher_2: ^1.2.4 collection: ^1.15.0 - cupertino_icons: ^1.0.5 curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 - device_info_plus: ^9.1.2 - device_preview: ^1.1.0 - dio: ^5.4.1 - disable_battery_optimization: ^1.1.0+1 + device_info_plus: ^10.1.0 + dio: ^5.4.3+1 + disable_battery_optimization: ^1.1.1 duration: ^3.0.12 - envied: ^0.3.0 - file_selector: ^1.0.1 - fluentui_system_icons: ^1.1.189 + envied: ^0.5.4+1 + file_picker: ^8.0.0+1 + file_selector: ^1.0.3 + fluentui_system_icons: ^1.1.234 flutter: sdk: flutter flutter_cache_manager: ^3.3.0 - flutter_desktop_tools: - git: - url: https://github.com/KRTirtho/flutter_desktop_tools.git - ref: 1f0bec3283626dcbd8ee2f54e238d096d8dea50e flutter_displaymode: ^0.6.0 flutter_feather_icons: ^2.0.0+1 flutter_hooks: ^0.20.5 flutter_inappwebview: ^6.0.0 flutter_localizations: sdk: flutter - flutter_native_splash: ^2.3.10 - flutter_riverpod: ^2.4.10 + flutter_native_splash: ^2.4.0 + flutter_riverpod: ^2.5.1 flutter_secure_storage: ^9.0.0 flutter_svg: ^1.1.6 form_validator: ^2.1.1 fuzzywuzzy: ^1.1.6 go_router: 12.1.3 # Stuck on this https://github.com/flutter/flutter/issues/140869 - google_fonts: ^6.1.0 + google_fonts: ^6.2.1 hive: ^2.2.3 hive_flutter: ^1.1.0 - hooks_riverpod: ^2.4.3 + hooks_riverpod: ^2.5.1 html: ^0.15.1 - http: ^1.2.0 - image_picker: ^1.0.4 - intl: ^0.18.0 - introduction_screen: ^3.0.2 + image_picker: ^1.1.0 + intl: any + introduction_screen: ^3.1.14 json_annotation: ^4.8.1 logger: ^2.0.2 - media_kit: ^1.1.3 - media_kit_libs_audio: ^1.0.3 + media_kit: ^1.1.10+1 + media_kit_libs_audio: ^1.0.4 metadata_god: ^0.5.2+1 mime: ^1.0.2 - package_info_plus: ^4.1.0 + package_info_plus: ^6.0.0 palette_generator: ^0.3.3 path: ^1.8.0 - path_provider: ^2.0.8 - permission_handler: ^11.0.1 - piped_client: - git: - url: https://github.com/KRTirtho/piped_client.git + path_provider: ^2.1.3 + permission_handler: ^11.3.1 + piped_client: ^0.1.1 popover: ^0.3.0 scrobblenaut: git: url: https://github.com/KRTirtho/scrobblenaut.git ref: dart-3-support scroll_to_index: ^3.0.1 - sidebarx: ^0.16.3 - shared_preferences: ^2.2.2 + sidebarx: ^0.17.1 + shared_preferences: ^2.2.3 skeleton_text: ^3.0.1 - smtc_windows: ^0.1.1 + smtc_windows: ^0.1.2 stroke_text: ^0.0.2 system_theme: ^2.1.0 titlebar_buttons: ^1.0.0 - url_launcher: ^6.1.7 - uuid: ^3.0.7 + url_launcher: ^6.2.6 + uuid: ^4.4.0 version: ^3.0.2 visibility_detector: ^0.4.0+2 - window_manager: ^0.3.1 + window_manager: ^0.3.9 window_size: git: url: https://github.com/google/flutter-desktop-embedding.git ref: a738913c8ce2c9f47515382d40827e794a334274 path: plugins/window_size - youtube_explode_dart: ^2.0.1 - simple_icons: ^7.10.0 - audio_service_mpris: ^0.1.0 - file_picker: ^6.0.0 + youtube_explode_dart: ^2.2.1 + simple_icons: ^10.1.3 jiosaavn: ^0.1.0 draggable_scrollbar: git: @@ -116,28 +107,33 @@ 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 + skeletonizer: ^1.1.1 + app_links: ^4.0.1 + win32_registry: ^1.1.3 flutter_sharing_intent: ^1.1.0 flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 - spotify: ^0.13.3 + spotify: + git: + url: https://github.com/KRTirtho/spotify-dart.git + ref: fix/explicit-to-json bonsoir: ^5.1.9 shelf: ^1.4.1 shelf_router: ^1.1.4 shelf_web_socket: ^1.0.4 - web_socket_channel: ^2.4.4 + web_socket_channel: ^2.4.5 lrc: ^1.0.2 pub_api_client: ^2.4.0 pubspec_parse: ^1.2.2 timezone: ^0.9.2 crypto: ^3.0.3 + local_notifier: ^0.1.6 + tray_manager: ^0.2.2 + http: ^1.2.1 dev_dependencies: build_runner: ^2.4.9 - envied_generator: ^0.3.0+3 - flutter_distributor: ^0.0.2 + envied_generator: ^0.5.4+1 flutter_gen_runner: ^5.4.0 flutter_launcher_icons: ^0.13.1 flutter_lints: ^3.0.1 @@ -147,12 +143,26 @@ dev_dependencies: sdk: flutter hive_generator: ^2.0.0 json_serializable: ^6.6.2 - freezed: ^2.4.6 - custom_lint: ^0.5.11 - riverpod_lint: ^2.1.1 + freezed: ^2.5.2 + custom_lint: ^0.6.4 + riverpod_lint: ^2.3.10 + process_run: ^0.14.2 + xml: ^6.5.0 + io: ^1.0.4 dependency_overrides: - system_tray: 2.0.2 + uuid: ^4.4.0 + system_tray: + # TODO: remove this when flutter_desktop_tools gets updated + # to use [MenuItemBase] instead of [MenuItem] + git: + url: https://github.com/antler119/system_tray + ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c + # media_kit_native_event_loop: # to fix "macro name must be an identifier" + # git: + # url: https://github.com/media-kit/media-kit + # path: media_kit_native_event_loop + # ref: main flutter: generate: true diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfee..aaf06929 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,209 @@ -{} \ No newline at end of file +{ + "ar": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "bn": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "ca": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "cs": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "de": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "es": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "eu": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "fa": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "fi": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "fr": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "hi": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "id": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "it": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "ja": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "ka": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "ko": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "ne": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "nl": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "pl": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "pt": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "ru": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "th": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "tr": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "uk": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "vi": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ], + + "zh": [ + "local_library", + "add_library_location", + "remove_library_location", + "local_tab", + "stats" + ] +} diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 6e1e3cb3..0c638eb7 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -1,13 +1,16 @@ +# Project-level configuration. cmake_minimum_required(VERSION 3.14) project(spotube LANGUAGES CXX) +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. set(BINARY_NAME "spotube") -cmake_policy(SET CMP0063 NEW) +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Configure build options. +# Define build configuration option. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if(IS_MULTICONFIG) set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" @@ -20,7 +23,7 @@ else() "Debug" "Profile" "Release") endif() endif() - +# Define settings for the Profile build mode. set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") @@ -30,6 +33,10 @@ set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") add_definitions(-DUNICODE -D_UNICODE) # Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_17) target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") @@ -38,14 +45,14 @@ function(APPLY_STANDARD_SETTINGS TARGET) target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") endfunction() -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") - # Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") add_subdirectory(${FLUTTER_MANAGED_DIR}) -# Application build +# Application build; see runner/CMakeLists.txt. add_subdirectory("runner") + # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) @@ -80,6 +87,12 @@ if(PLUGIN_BUNDLED_LIBRARIES) COMPONENT Runtime) endif() +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index d8a9db29..f2dd9714 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -44,6 +45,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("SystemThemePlugin")); SystemTrayPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SystemTrayPlugin")); + TrayManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 90292744..f4e14280 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -14,6 +14,7 @@ list(APPEND FLUTTER_PLUGIN_LIST screen_retriever system_theme system_tray + tray_manager url_launcher_windows window_manager window_size diff --git a/windows/packaging/exe/inno_setup.iss b/windows/packaging/exe/inno_setup.iss index 64acc2b3..dbb8082b 100644 --- a/windows/packaging/exe/inno_setup.iss +++ b/windows/packaging/exe/inno_setup.iss @@ -12,7 +12,7 @@ AppPublisher={{PUBLISHER_NAME}} AppPublisherURL={{PUBLISHER_URL}} AppSupportURL={{PUBLISHER_URL}} AppUpdatesURL={{PUBLISHER_URL}} -DefaultDirName={{INSTALL_DIR_NAME}} +DefaultDirName={autopf}\{{DISPLAY_NAME}} DisableProgramGroupPage=yes OutputDir=. OutputBaseFilename={{OUTPUT_BASE_FILENAME}} diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index e8fccc8a..27632667 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -60,16 +60,16 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" // Version // -#ifdef FLUTTER_BUILD_NUMBER -#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else -#define VERSION_AS_NUMBER 1,0,0 +#define VERSION_AS_NUMBER 1,0,0,0 #endif -#ifdef FLUTTER_BUILD_NAME -#define VERSION_AS_STRING #FLUTTER_BUILD_NAME +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION #else -#define VERSION_AS_STRING "%{{SPOTUBE_VERSION}}%" +#define VERSION_AS_STRING "3.7.0" #endif VS_VERSION_INFO VERSIONINFO @@ -93,7 +93,7 @@ BEGIN VALUE "FileDescription", "Spotube" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "spotube" "\0" - VALUE "LegalCopyright", "Copyright (C) 2022 oss.krtirtho. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 oss.krtirtho. All rights reserved." "\0" VALUE "OriginalFilename", "spotube.exe" "\0" VALUE "ProductName", "spotube" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" From 2b5fd35529f4036278b183ecbabc0d9fa760f297 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 3 Jun 2024 13:52:47 +0600 Subject: [PATCH 100/261] chore: update translations and generate credits --- .gitignore | 2 + README.md | 99 +++++++++++++++++++++++---------------------- lib/l10n/app_ar.arb | 7 +++- lib/l10n/app_bn.arb | 7 +++- lib/l10n/app_ca.arb | 7 +++- lib/l10n/app_cs.arb | 7 +++- lib/l10n/app_de.arb | 7 +++- lib/l10n/app_es.arb | 7 +++- lib/l10n/app_eu.arb | 7 +++- lib/l10n/app_fa.arb | 7 +++- lib/l10n/app_fi.arb | 7 +++- lib/l10n/app_fr.arb | 7 +++- lib/l10n/app_hi.arb | 7 +++- lib/l10n/app_id.arb | 7 +++- lib/l10n/app_it.arb | 7 +++- lib/l10n/app_ja.arb | 7 +++- lib/l10n/app_ka.arb | 7 +++- lib/l10n/app_ko.arb | 7 +++- lib/l10n/app_ne.arb | 7 +++- lib/l10n/app_nl.arb | 7 +++- lib/l10n/app_pl.arb | 7 +++- lib/l10n/app_pt.arb | 7 +++- lib/l10n/app_ru.arb | 7 +++- lib/l10n/app_th.arb | 7 +++- lib/l10n/app_tr.arb | 7 +++- lib/l10n/app_uk.arb | 7 +++- lib/l10n/app_vi.arb | 7 +++- lib/l10n/app_zh.arb | 7 +++- 28 files changed, 208 insertions(+), 75 deletions(-) diff --git a/.gitignore b/.gitignore index 96d81087..4f9ebc28 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,5 @@ android/key.properties .fvm/flutter_sdk **/pb_data + +tm.json diff --git a/README.md b/README.md index f2666fbc..5db4d5ad 100644 --- a/README.md +++ b/README.md @@ -210,116 +210,117 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 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. [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. [args](https://pub.dev/packages/args) - Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options. 1. [async](https://pub.dev/packages/async) - Utility functions and classes related to the 'dart:async' library. +1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification. 1. [audio_service](https://pub.dev/packages/audio_service) - Flutter plugin to play audio in the background while the screen is off. 1. [audio_session](https://github.com/ryanheise/audio_session) - Sets the iOS audio session category and Android audio attributes for your app, and manages your app's audio focus, mixing and ducking behaviour. 1. [auto_size_text](https://github.com/leisim/auto_size_text) - Flutter widget that automatically resizes text to fit perfectly within its bounds. +1. [bonsoir](https://bonsoir.skyost.eu) - A Zeroconf library that allows you to discover network services and to broadcast your own. Based on Apple Bonjour and Android NSD. +1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. 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. [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. [crypto](https://pub.dev/packages/crypto) - Implementations of SHA, MD5, and HMAC cryptographic functions. 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. +1. [custom_lint](https://pub.dev/packages/custom_lint) - Lint rules are a powerful way to improve the maintainability of a project. Custom Lint allows package authors and developers to easily write custom lint rules. +1. [dart_discord_rpc](https://github.com/alexmercerind/dart_discord_rpc) - Discord Rich Presence for Flutter & Dart apps & games. 1. [dbus](https://github.com/canonical/dbus.dart) - A native Dart implementation of the D-Bus message bus client. This package allows Dart applications to directly access services on the Linux desktop. 1. [device_info_plus](https://plus.fluttercommunity.dev/) - Flutter plugin providing detailed information about the device (make, model, etc.), and Android or iOS version the app is running on. -1. [device_preview](https://github.com/aloisdeniel/flutter_device_preview) - Approximate how your Flutter app looks and performs on another device. 1. [dio](https://github.com/cfug/dio) - A powerful HTTP networking package,supports Interceptors,Aborting and canceling a request,Custom adapters, Transformers, etc. 1. [disable_battery_optimization](https://github.com/pvsvamsi/Disable-Battery-Optimizations) - Flutter plugin to check and disable battery optimizations. Also shows custom steps to disable the optimizations in devices like mi, xiaomi, samsung, oppo, huawei, oneplus etc +1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item. 1. [duration](https://github.com/desktop-dart/duration) - Utilities to make working with 'Duration's easier. Formats duration in human readable form and also parses duration in human readable form to Dart's Duration. +1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. 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_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. [file_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI. 1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft. +1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. 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. +1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs. 1. [flutter_hooks](https://github.com/rrousselGit/flutter_hooks) - A flutter implementation of React hooks. It adds a new kind of widget with enhanced code reuse. 1. [flutter_inappwebview](https://inappwebview.dev/) - A Flutter plugin that allows you to add an inline webview, to use an headless webview, and to open an in-app browser window. +1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon. +1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices. 1. [flutter_native_splash](https://pub.dev/packages/flutter_native_splash) - Customize Flutter's default white native splash screen with background color and splash image. Supports dark mode, full screen, and more. 1. [flutter_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. 1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android. +1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. 1. [flutter_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. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. +1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. 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. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views. 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. [google_fonts](https://pub.dev/packages/google_fonts) - A Flutter package to use fonts from fonts.google.com. Supports HTTP fetching, caching, and asset bundling. -1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256. 1. [hive_flutter](https://github.com/hivedb/hive/tree/master/hive_flutter) - Extension for Hive. Makes it easier to use Hive in Flutter apps. +1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class. +1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256. 1. [hooks_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. +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. [html](https://pub.dev/packages/html) - APIs for parsing and manipulating HTML content outside the browser. 1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests. 1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. 1. [intl](https://pub.dev/packages/intl) - Contains code to deal with internationalized/localized messages, date and number formatting and parsing, bi-directional text, and other internationalization issues. 1. [introduction_screen](https://pub.dev/packages/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities +1. [io](https://pub.dev/packages/io) - Utilities for the Dart VM Runtime including support for ANSI colors, file copying, and standard exit code values. +1. [jiosaavn](https://github.com/KRTirtho/jiosaavn) - Unofficial API client for jiosaavn.com 1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package. +1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. +1. [local_notifier](https://github.com/leanflutter/local_notifier) - This plugin allows Flutter desktop apps to displaying local notifications. 1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs. -1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular. +1. [lrc](https://pub.dev/packages/lrc) - A Dart-only package that creates, parses, and handles LRC, which is a format that stores song lyrics. 1. [media_kit_libs_audio](https://github.com/media-kit/media-kit.git) - package:media_kit audio (only) playback native libraries for all platforms. +1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular. 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. [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](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. [permission_handler](https://pub.dev/packages/permission_handler) - Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions. +1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video 1. [popover](https://github.com/minikin/popover) - A popover is a transient view that appears above other content onscreen when you tap a control or in an area. +1. [process_run](https://github.com/tekartik/process_run.dart/blob/master/packages/process_run) - Process run helpers for Linux/Win/Mac and which like feature for finding executables. +1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. +1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. +1. [riverpod_lint](https://riverpod.dev) - Riverpod_lint is a developer tool for users of Riverpod, designed to help stop common issues and simplify repetitive tasks. +1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. 1. [scroll_to_index](https://github.com/quire-io/scroll-to-index) - Scroll to a specific child of any scrollable widget in Flutter -1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget 1. [shared_preferences](https://pub.dev/packages/shared_preferences) - Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. +1. [shelf_router](https://pub.dev/packages/shelf_router) - A convenient request router for the shelf web-framework, with support for URL-parameters, nested routers and routers generated from source annotations. +1. [shelf_web_socket](https://pub.dev/packages/shelf_web_socket) - A shelf handler that wires up a listener for every connection. +1. [shelf](https://pub.dev/packages/shelf) - A model for web server middleware that encourages composition and easy reuse. +1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget +1. [simple_icons](https://teavelopment.com/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands. 1. [skeleton_text](https://github.com/101Loop/Skeleton-Text) - A package that provides an easy way to add skeleton text loading animation in Flutter project. This project is a part of 101Loop community. +1. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort. +1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework 1. [smtc_windows](https://github.com/KRTirtho/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet. +1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. 1. [stroke_text](https://github.com/MohamedAbd0/stroke_text) - A Simple Flutter plugin for applying stroke (border) style to a text widget 1. [system_theme](https://pub.dev/packages/system_theme) - A plugin to get the current system theme info. Supports Android, Web, Windows, Linux and macOS +1. [timezone](https://pub.dev/packages/timezone) - Time zone database and time zone aware DateTime. 1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos. +1. [tray_manager](https://github.com/leanflutter/tray_manager) - This plugin allows Flutter desktop apps to defines system tray. 1. [url_launcher](https://pub.dev/packages/url_launcher) - Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. 1. [uuid](https://pub.dev/packages/uuid) - RFC4122 (v1, v4, v5, v6, v7, v8) UUID Generator and Parser for Dart 1. [version](https://github.com/dartninja/version) - Provides a simple class for parsing and comparing semantic versions as defined by http://semver.org/ -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://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 1. [very_good_infinite_list](https://github.com/VeryGoodOpenSource/very_good_infinite_list) - A library for easily displaying paginated data, created by Very Good Ventures. Great for activity feeds, news feeds, and more. -1. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views. -1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework -1. [html_unescape](https://github.com/filiph/html_unescape) - A small library for un-escaping HTML. Supports all Named Character References, Decimal Character References and Hexadecimal Character References. -1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter -1. [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://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry. -1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. -1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. -1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. -1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. -1. [bonsoir](https://bonsoir.skyost.eu) - A Zeroconf library that allows you to discover network services and to broadcast your own. Based on Apple Bonjour and Android NSD. -1. [shelf](https://pub.dev/packages/shelf) - A model for web server middleware that encourages composition and easy reuse. -1. [shelf_router](https://pub.dev/packages/shelf_router) - A convenient request router for the shelf web-framework, with support for URL-parameters, nested routers and routers generated from source annotations. -1. [shelf_web_socket](https://pub.dev/packages/shelf_web_socket) - A shelf handler that wires up a listener for every connection. +1. [visibility_detector](https://pub.dev/packages/visibility_detector) - A widget that detects the visibility of its child and notifies a callback. 1. [web_socket_channel](https://pub.dev/packages/web_socket_channel) - StreamChannel wrappers for WebSockets. Provides a cross-platform WebSocketChannel API, a cross-platform implementation of that API that communicates over an underlying StreamChannel. -1. [lrc](https://pub.dev/packages/lrc) - A Dart-only package that creates, parses, and handles LRC, which is a format that stores song lyrics. -1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. -1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. -1. [timezone](https://pub.dev/packages/timezone) - Time zone database and time zone aware DateTime. -1. [crypto](https://pub.dev/packages/crypto) - Implementations of SHA, MD5, and HMAC cryptographic functions. -1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. -1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. -1. [flutter_distributor](https://distributor.leanflutter.dev) - A complete tool for packaging and publishing your Flutter apps. -1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs. -1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon. -1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices. -1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class. -1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. -1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. -1. [custom_lint](https://pub.dev/packages/custom_lint) - Lint rules are a powerful way to improve the maintainability of a project. Custom Lint allows package authors and developers to easily write custom lint rules. -1. [riverpod_lint](https://riverpod.dev) - Riverpod_lint is a developer tool for users of Riverpod, designed to help stop common issues and simplify repetitive tasks. -1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development -1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video -1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. +1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter +1. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry. +1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window. 1. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter. -1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item. -1. [dart_discord_rpc](https://github.com/alexmercerind/dart_discord_rpc) - Discord Rich Presence for Flutter & Dart apps & games. +1. [xml](https://github.com/renggli/dart-xml) - A lightweight library for parsing, traversing, querying, transforming and building XML documents. +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.

© Copyright Spotube 2024

diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 68308ba1..b474ec7e 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -320,5 +320,10 @@ "select": "اختر", "connect_client_alert": "أنت تتم التحكم بواسطة {client}", "this_device": "هذا الجهاز", - "remote": "بعيد" + "remote": "بعيد", + "local_library": "المكتبة المحلية", + "add_library_location": "أضف إلى المكتبة", + "remove_library_location": "إزالة من المكتبة", + "local_tab": "محلي", + "stats": "إحصائيات" } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 506e78bc..2cf8dd43 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -320,5 +320,10 @@ "select": "নির্বাচন করুন", "connect_client_alert": "আপনি {client} দ্বারা নিয়ন্ত্রিত হচ্ছেন", "this_device": "এই ডিভাইস", - "remote": "রিমোট" + "remote": "রিমোট", + "local_library": "স্থানীয় লাইব্রেরি", + "add_library_location": "লাইব্রেরিতে যোগ করুন", + "remove_library_location": "লাইব্রেরি থেকে সরান", + "local_tab": "স্থানীয়", + "stats": "পরিসংখ্যান" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index 8faa0d09..ca4b019a 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -320,5 +320,10 @@ "select": "Selecciona", "connect_client_alert": "Estàs sent controlat per {client}", "this_device": "Aquest dispositiu", - "remote": "Remot" + "remote": "Remot", + "local_library": "Biblioteca local", + "add_library_location": "Afegeix a la biblioteca", + "remove_library_location": "Elimina de la biblioteca", + "local_tab": "Local", + "stats": "Estadístiques" } \ No newline at end of file diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 52f5bcf8..7191c108 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -320,5 +320,10 @@ "select": "Vybrat", "connect_client_alert": "Zařízení je ovládáno z {client}", "this_device": "Toto zařízení", - "remote": "Ovladač" + "remote": "Ovladač", + "local_library": "Místní knihovna", + "add_library_location": "Přidat do knihovny", + "remove_library_location": "Odebrat z knihovny", + "local_tab": "Místní", + "stats": "Statistiky" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 77435d67..c455e08a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -320,5 +320,10 @@ "select": "Auswählen", "connect_client_alert": "Du wirst von {client} gesteuert", "this_device": "Dieses Gerät", - "remote": "Fernbedienung" + "remote": "Fernbedienung", + "local_library": "Lokale Bibliothek", + "add_library_location": "Zur Bibliothek hinzufügen", + "remove_library_location": "Aus der Bibliothek entfernen", + "local_tab": "Lokal", + "stats": "Statistiken" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 11617b42..6558c743 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -320,5 +320,10 @@ "select": "Seleccionar", "connect_client_alert": "Estás siendo controlado por {client}", "this_device": "Este dispositivo", - "remote": "Remoto" + "remote": "Remoto", + "local_library": "Biblioteca local", + "add_library_location": "Añadir a la biblioteca", + "remove_library_location": "Eliminar de la biblioteca", + "local_tab": "Local", + "stats": "Estadísticas" } \ No newline at end of file diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb index 9a4ebb46..fb00a925 100644 --- a/lib/l10n/app_eu.arb +++ b/lib/l10n/app_eu.arb @@ -320,5 +320,10 @@ "select": "Aukeratu", "connect_client_alert": "{client} gailuak kontrolatzen zaitu", "this_device": "Gailu hau", - "remote": "Urrunekoa" + "remote": "Urrunekoa", + "local_library": "Liburutegi lokala", + "add_library_location": "Gehitu liburutegira", + "remove_library_location": "Kendu liburutegitik", + "local_tab": "Tokiko", + "stats": "Estatistikak" } \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 8a0bee3a..b939de59 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -320,5 +320,10 @@ "select": "انتخاب", "connect_client_alert": "شما توسط {client} کنترل می‌شوید", "this_device": "این دستگاه", - "remote": "راه‌دور" + "remote": "راه‌دور", + "local_library": "کتابخانه محلی", + "add_library_location": "اضافه کردن به کتابخانه", + "remove_library_location": "حذف از کتابخانه", + "local_tab": "محلی", + "stats": "آمار" } \ No newline at end of file diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 35470791..d0767e95 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -320,5 +320,10 @@ "select": "Valitse", "connect_client_alert": "{client} ohjaa sinua", "this_device": "Tämä laite", - "remote": "Etä" + "remote": "Etä", + "local_library": "Paikallinen kirjasto", + "add_library_location": "Lisää kirjastoon", + "remove_library_location": "Poista kirjastosta", + "local_tab": "Paikallinen", + "stats": "Tilastot" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cabcb8e1..6bd2d0f8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -320,5 +320,10 @@ "select": "Sélectionner", "connect_client_alert": "Vous êtes contrôlé par {client}", "this_device": "Cet appareil", - "remote": "À distance" + "remote": "À distance", + "local_library": "Bibliothèque locale", + "add_library_location": "Ajouter à la bibliothèque", + "remove_library_location": "Retirer de la bibliothèque", + "local_tab": "Local", + "stats": "Statistiques" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a72e136e..7dc809c7 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -320,5 +320,10 @@ "select": "चयन करें", "connect_client_alert": "आप {client} द्वारा नियंत्रित हो रहे हैं", "this_device": "यह उपकरण", - "remote": "रिमोट" + "remote": "रिमोट", + "local_library": "स्थानीय पुस्तकालय", + "add_library_location": "पुस्तकालय में जोड़ें", + "remove_library_location": "पुस्तकालय से हटाएं", + "local_tab": "स्थानीय", + "stats": "आंकड़े" } \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index b94cdd28..669f5e2a 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -320,5 +320,10 @@ "select": "Pilih", "connect_client_alert": "Anda dikendalikan oleh {client}", "this_device": "Perangkat Ini", - "remote": "Remot" + "remote": "Remot", + "local_library": "Perpustakaan lokal", + "add_library_location": "Tambahkan ke perpustakaan", + "remove_library_location": "Hapus dari perpustakaan", + "local_tab": "Lokal", + "stats": "Statistik" } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index bb1881d6..9ba30acc 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -321,5 +321,10 @@ "select": "Seleziona", "connect_client_alert": "Stai venendo controllato da {client}", "this_device": "Questo dispositivo", - "remote": "Remoto" + "remote": "Remoto", + "local_library": "Biblioteca locale", + "add_library_location": "Aggiungi alla biblioteca", + "remove_library_location": "Rimuovi dalla biblioteca", + "local_tab": "Locale", + "stats": "Statistiche" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ab759404..35e76b69 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -320,5 +320,10 @@ "select": "選択する", "connect_client_alert": "{client} によって操作されています", "this_device": "このデバイス", - "remote": "リモート" + "remote": "リモート", + "local_library": "ローカルライブラリ", + "add_library_location": "ライブラリに追加", + "remove_library_location": "ライブラリから削除", + "local_tab": "ローカル", + "stats": "統計" } \ No newline at end of file diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb index 3da06444..28fcc26a 100644 --- a/lib/l10n/app_ka.arb +++ b/lib/l10n/app_ka.arb @@ -320,5 +320,10 @@ "select": "არჩევა", "connect_client_alert": "თქვენ კონტროლირებული ხართ {client} მოწყობილობით", "this_device": "ეს მოწყობილობა", - "remote": "დისტანციური" + "remote": "დისტანციური", + "local_library": "ადგილობრივი ბიბლიოთეკა", + "add_library_location": "ბიბლიოთეკაში დამატება", + "remove_library_location": "ბიბლიოთეკიდან წაშლა", + "local_tab": "ადგილობრივი", + "stats": "სტატისტიკა" } \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c94f8142..cb6e0999 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -321,5 +321,10 @@ "select": "선택", "connect_client_alert": "{client}님에 의해 제어되고 있습니다", "this_device": "이 장치", - "remote": "원격" + "remote": "원격", + "local_library": "로컬 도서관", + "add_library_location": "도서관에 추가", + "remove_library_location": "도서관에서 제거", + "local_tab": "로컬", + "stats": "통계" } \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb index 4085b00e..f8e8d46a 100644 --- a/lib/l10n/app_ne.arb +++ b/lib/l10n/app_ne.arb @@ -320,5 +320,10 @@ "select": "चयन गर्नुहोस्", "connect_client_alert": "तपाईंलाई {client} द्वारा नियन्त्रित गरिएको छ", "this_device": "यो उपकरण", - "remote": "दूरसंचार" + "remote": "दूरसंचार", + "local_library": "स्थानिय पुस्तकालय", + "add_library_location": "पुस्तकालयमा थप्नुहोस्", + "remove_library_location": "पुस्तकालयबाट हटाउनुहोस्", + "local_tab": "स्थानिय", + "stats": "तथ्याङ्क" } \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 0a04c40b..aa5c846d 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -321,5 +321,10 @@ "select": "Selecteren", "connect_client_alert": "Je wordt gecontroleerd door {client}", "this_device": "Dit apparaat", - "remote": "Afstandsbediening" + "remote": "Afstandsbediening", + "local_library": "Lokale bibliotheek", + "add_library_location": "Toevoegen aan bibliotheek", + "remove_library_location": "Verwijderen uit bibliotheek", + "local_tab": "Lokaal", + "stats": "Statistieken" } \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 9ce31187..2c4e8369 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -320,5 +320,10 @@ "select": "Wybierz", "connect_client_alert": "Jesteś sterowany przez {client}", "this_device": "To urządzenie", - "remote": "Zdalny" + "remote": "Zdalny", + "local_library": "Biblioteka lokalna", + "add_library_location": "Dodaj do biblioteki", + "remove_library_location": "Usuń z biblioteki", + "local_tab": "Lokalny", + "stats": "Statystyki" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 53732589..88cf5cb3 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -320,5 +320,10 @@ "select": "Selecionar", "connect_client_alert": "Você está sendo controlado por {client}", "this_device": "Este dispositivo", - "remote": "Remoto" + "remote": "Remoto", + "local_library": "Biblioteca local", + "add_library_location": "Adicionar à biblioteca", + "remove_library_location": "Remover da biblioteca", + "local_tab": "Local", + "stats": "Estatísticas" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a18e02e7..0a1c1c22 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -320,5 +320,10 @@ "select": "Выбрать", "connect_client_alert": "Вас контролирует {client}", "this_device": "Это устройство", - "remote": "Дистанционное управление" + "remote": "Дистанционное управление", + "local_library": "Местная библиотека", + "add_library_location": "Добавить в библиотеку", + "remove_library_location": "Удалить из библиотеки", + "local_tab": "Местный", + "stats": "Статистика" } \ No newline at end of file diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 866929fa..60ced74b 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -321,5 +321,10 @@ "select": "เลือก", "connect_client_alert": "คุณกำลังถูกควบคุมโดย {client}", "this_device": "อุปกรณ์นี้", - "remote": "ระยะไกล" + "remote": "ระยะไกล", + "local_library": "ห้องสมุดท้องถิ่น", + "add_library_location": "เพิ่มในห้องสมุด", + "remove_library_location": "ลบออกจากห้องสมุด", + "local_tab": "ท้องถิ่น", + "stats": "สถิติ" } \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index aab6bc6d..b329cfa7 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -320,5 +320,10 @@ "select": "Seç", "connect_client_alert": "{client} tarafından kontrol ediliyorsun.", "this_device": "Bu cihaz", - "remote": "Yönet" + "remote": "Yönet", + "local_library": "Yerel kütüphane", + "add_library_location": "Kütüphaneye ekle", + "remove_library_location": "Kütüphaneden çıkar", + "local_tab": "Yerel", + "stats": "İstatistikler" } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 4208a3d2..d056524e 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -320,5 +320,10 @@ "select": "Вибрати", "connect_client_alert": "Вас керує {client}", "this_device": "Цей пристрій", - "remote": "Віддалений" + "remote": "Віддалений", + "local_library": "Місцева бібліотека", + "add_library_location": "Додати до бібліотеки", + "remove_library_location": "Видалити з бібліотеки", + "local_tab": "Місцевий", + "stats": "Статистика" } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 6115fc0c..6bbd6cb6 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -320,5 +320,10 @@ "select": "Chọn", "connect_client_alert": "Bạn đang được điều khiển bởi {client}", "this_device": "Thiết bị này", - "remote": "Từ xa" + "remote": "Từ xa", + "local_library": "Thư viện địa phương", + "add_library_location": "Thêm vào thư viện", + "remove_library_location": "Xóa khỏi thư viện", + "local_tab": "Địa phương", + "stats": "Thống kê" } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index da5254a3..b145f97b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -320,5 +320,10 @@ "select": "选择", "connect_client_alert": "您正在被 {client} 控制", "this_device": "此设备", - "remote": "远程" + "remote": "远程", + "local_library": "本地图书馆", + "add_library_location": "添加到图书馆", + "remove_library_location": "从图书馆中删除", + "local_tab": "本地", + "stats": "统计" } \ No newline at end of file From ed48d25add1513a34e7c20cf71f1ce4106455612 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 3 Jun 2024 13:52:47 +0600 Subject: [PATCH 101/261] chore: update translations and generate credits --- .gitignore | 2 + README.md | 99 +++++++++++++++++++++++---------------------- lib/l10n/app_ar.arb | 7 +++- lib/l10n/app_bn.arb | 7 +++- lib/l10n/app_ca.arb | 7 +++- lib/l10n/app_cs.arb | 7 +++- lib/l10n/app_de.arb | 7 +++- lib/l10n/app_es.arb | 7 +++- lib/l10n/app_eu.arb | 7 +++- lib/l10n/app_fa.arb | 7 +++- lib/l10n/app_fi.arb | 7 +++- lib/l10n/app_fr.arb | 7 +++- lib/l10n/app_hi.arb | 7 +++- lib/l10n/app_id.arb | 7 +++- lib/l10n/app_it.arb | 7 +++- lib/l10n/app_ja.arb | 7 +++- lib/l10n/app_ka.arb | 7 +++- lib/l10n/app_ko.arb | 7 +++- lib/l10n/app_ne.arb | 7 +++- lib/l10n/app_nl.arb | 7 +++- lib/l10n/app_pl.arb | 7 +++- lib/l10n/app_pt.arb | 7 +++- lib/l10n/app_ru.arb | 7 +++- lib/l10n/app_th.arb | 7 +++- lib/l10n/app_tr.arb | 7 +++- lib/l10n/app_uk.arb | 7 +++- lib/l10n/app_vi.arb | 7 +++- lib/l10n/app_zh.arb | 7 +++- 28 files changed, 208 insertions(+), 75 deletions(-) diff --git a/.gitignore b/.gitignore index 96d81087..4f9ebc28 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,5 @@ android/key.properties .fvm/flutter_sdk **/pb_data + +tm.json diff --git a/README.md b/README.md index f2666fbc..5db4d5ad 100644 --- a/README.md +++ b/README.md @@ -210,116 +210,117 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 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. [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. [args](https://pub.dev/packages/args) - Library for defining parsers for parsing raw command-line arguments into a set of options and values using GNU and POSIX style options. 1. [async](https://pub.dev/packages/async) - Utility functions and classes related to the 'dart:async' library. +1. [audio_service_mpris](https://github.com/bdrazhzhov/audio-service-mpris) - audio_service platform interface supporting Media Player Remote Interfacing Specification. 1. [audio_service](https://pub.dev/packages/audio_service) - Flutter plugin to play audio in the background while the screen is off. 1. [audio_session](https://github.com/ryanheise/audio_session) - Sets the iOS audio session category and Android audio attributes for your app, and manages your app's audio focus, mixing and ducking behaviour. 1. [auto_size_text](https://github.com/leisim/auto_size_text) - Flutter widget that automatically resizes text to fit perfectly within its bounds. +1. [bonsoir](https://bonsoir.skyost.eu) - A Zeroconf library that allows you to discover network services and to broadcast your own. Based on Apple Bonjour and Android NSD. +1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. 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. [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. [crypto](https://pub.dev/packages/crypto) - Implementations of SHA, MD5, and HMAC cryptographic functions. 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. +1. [custom_lint](https://pub.dev/packages/custom_lint) - Lint rules are a powerful way to improve the maintainability of a project. Custom Lint allows package authors and developers to easily write custom lint rules. +1. [dart_discord_rpc](https://github.com/alexmercerind/dart_discord_rpc) - Discord Rich Presence for Flutter & Dart apps & games. 1. [dbus](https://github.com/canonical/dbus.dart) - A native Dart implementation of the D-Bus message bus client. This package allows Dart applications to directly access services on the Linux desktop. 1. [device_info_plus](https://plus.fluttercommunity.dev/) - Flutter plugin providing detailed information about the device (make, model, etc.), and Android or iOS version the app is running on. -1. [device_preview](https://github.com/aloisdeniel/flutter_device_preview) - Approximate how your Flutter app looks and performs on another device. 1. [dio](https://github.com/cfug/dio) - A powerful HTTP networking package,supports Interceptors,Aborting and canceling a request,Custom adapters, Transformers, etc. 1. [disable_battery_optimization](https://github.com/pvsvamsi/Disable-Battery-Optimizations) - Flutter plugin to check and disable battery optimizations. Also shows custom steps to disable the optimizations in devices like mi, xiaomi, samsung, oppo, huawei, oneplus etc +1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item. 1. [duration](https://github.com/desktop-dart/duration) - Utilities to make working with 'Duration's easier. Formats duration in human readable form and also parses duration in human readable form to Dart's Duration. +1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. 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_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. [file_selector](https://pub.dev/packages/file_selector) - Flutter plugin for opening and saving files, or selecting directories, using native file selection UI. 1. [fluentui_system_icons](https://github.com/microsoft/fluentui-system-icons/tree/main) - Fluent UI System Icons are a collection of familiar, friendly and modern icons from Microsoft. +1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. 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. +1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs. 1. [flutter_hooks](https://github.com/rrousselGit/flutter_hooks) - A flutter implementation of React hooks. It adds a new kind of widget with enhanced code reuse. 1. [flutter_inappwebview](https://inappwebview.dev/) - A Flutter plugin that allows you to add an inline webview, to use an headless webview, and to open an in-app browser window. +1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon. +1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices. 1. [flutter_native_splash](https://pub.dev/packages/flutter_native_splash) - Customize Flutter's default white native splash screen with background color and splash image. Supports dark mode, full screen, and more. 1. [flutter_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. 1. [flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage) - Flutter Secure Storage provides API to store data in secure storage. Keychain is used in iOS, KeyStore based solution is used in Android. +1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. 1. [flutter_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. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. +1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. 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. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views. 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. [google_fonts](https://pub.dev/packages/google_fonts) - A Flutter package to use fonts from fonts.google.com. Supports HTTP fetching, caching, and asset bundling. -1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256. 1. [hive_flutter](https://github.com/hivedb/hive/tree/master/hive_flutter) - Extension for Hive. Makes it easier to use Hive in Flutter apps. +1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class. +1. [hive](https://github.com/hivedb/hive/tree/master/hive) - Lightweight and blazing fast key-value database written in pure Dart. Strongly encrypted using AES-256. 1. [hooks_riverpod](https://riverpod.dev) - A reactive caching and data-binding framework. Riverpod makes working with asynchronous code a breeze. +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. [html](https://pub.dev/packages/html) - APIs for parsing and manipulating HTML content outside the browser. 1. [http](https://pub.dev/packages/http) - A composable, multi-platform, Future-based API for HTTP requests. 1. [image_picker](https://pub.dev/packages/image_picker) - Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. 1. [intl](https://pub.dev/packages/intl) - Contains code to deal with internationalized/localized messages, date and number formatting and parsing, bi-directional text, and other internationalization issues. 1. [introduction_screen](https://pub.dev/packages/introduction_screen) - Introduction/Onboarding package for flutter app with some customizations possibilities +1. [io](https://pub.dev/packages/io) - Utilities for the Dart VM Runtime including support for ANSI colors, file copying, and standard exit code values. +1. [jiosaavn](https://github.com/KRTirtho/jiosaavn) - Unofficial API client for jiosaavn.com 1. [json_annotation](https://pub.dev/packages/json_annotation) - Classes and helper functions that support JSON code generation via the `json_serializable` package. +1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. +1. [local_notifier](https://github.com/leanflutter/local_notifier) - This plugin allows Flutter desktop apps to displaying local notifications. 1. [logger](https://pub.dev/packages/logger) - Small, easy to use and extensible logger which prints beautiful logs. -1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular. +1. [lrc](https://pub.dev/packages/lrc) - A Dart-only package that creates, parses, and handles LRC, which is a format that stores song lyrics. 1. [media_kit_libs_audio](https://github.com/media-kit/media-kit.git) - package:media_kit audio (only) playback native libraries for all platforms. +1. [media_kit](https://github.com/media-kit/media-kit) - A cross-platform video player & audio player for Flutter & Dart. Performant, stable, feature-proof & modular. 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. [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](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. [permission_handler](https://pub.dev/packages/permission_handler) - Permission plugin for Flutter. This plugin provides a cross-platform (iOS, Android) API to request and check permissions. +1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video 1. [popover](https://github.com/minikin/popover) - A popover is a transient view that appears above other content onscreen when you tap a control or in an area. +1. [process_run](https://github.com/tekartik/process_run.dart/blob/master/packages/process_run) - Process run helpers for Linux/Win/Mac and which like feature for finding executables. +1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. +1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. +1. [riverpod_lint](https://riverpod.dev) - Riverpod_lint is a developer tool for users of Riverpod, designed to help stop common issues and simplify repetitive tasks. +1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. 1. [scroll_to_index](https://github.com/quire-io/scroll-to-index) - Scroll to a specific child of any scrollable widget in Flutter -1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget 1. [shared_preferences](https://pub.dev/packages/shared_preferences) - Flutter plugin for reading and writing simple key-value pairs. Wraps NSUserDefaults on iOS and SharedPreferences on Android. +1. [shelf_router](https://pub.dev/packages/shelf_router) - A convenient request router for the shelf web-framework, with support for URL-parameters, nested routers and routers generated from source annotations. +1. [shelf_web_socket](https://pub.dev/packages/shelf_web_socket) - A shelf handler that wires up a listener for every connection. +1. [shelf](https://pub.dev/packages/shelf) - A model for web server middleware that encourages composition and easy reuse. +1. [sidebarx](https://github.com/Frezyx/sidebarx) - flutter multiplatform navigation sidebar / side navigationbar / drawer widget +1. [simple_icons](https://teavelopment.com/) - The Simple Icon pack available as Flutter Icons. Provides over 1500 Free SVG icons for popular brands. 1. [skeleton_text](https://github.com/101Loop/Skeleton-Text) - A package that provides an easy way to add skeleton text loading animation in Flutter project. This project is a part of 101Loop community. +1. [skeletonizer](https://github.com/Milad-Akarie/skeletonizer) - Converts already built widgets into skeleton loaders with no extra effort. +1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework 1. [smtc_windows](https://github.com/KRTirtho/smtc_windows) - Windows `SystemMediaTransportControls` implementation for Flutter giving access to Windows OS Media Control applet. +1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. 1. [stroke_text](https://github.com/MohamedAbd0/stroke_text) - A Simple Flutter plugin for applying stroke (border) style to a text widget 1. [system_theme](https://pub.dev/packages/system_theme) - A plugin to get the current system theme info. Supports Android, Web, Windows, Linux and macOS +1. [timezone](https://pub.dev/packages/timezone) - Time zone database and time zone aware DateTime. 1. [titlebar_buttons](https://github.com/gtk-flutter/titlebar_buttons) - A package which provides most of the titlebar buttons from windows, linux and macos. +1. [tray_manager](https://github.com/leanflutter/tray_manager) - This plugin allows Flutter desktop apps to defines system tray. 1. [url_launcher](https://pub.dev/packages/url_launcher) - Flutter plugin for launching a URL. Supports web, phone, SMS, and email schemes. 1. [uuid](https://pub.dev/packages/uuid) - RFC4122 (v1, v4, v5, v6, v7, v8) UUID Generator and Parser for Dart 1. [version](https://github.com/dartninja/version) - Provides a simple class for parsing and comparing semantic versions as defined by http://semver.org/ -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://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 1. [very_good_infinite_list](https://github.com/VeryGoodOpenSource/very_good_infinite_list) - A library for easily displaying paginated data, created by Very Good Ventures. Great for activity feeds, news feeds, and more. -1. [gap](https://github.com/letsar/gap) - Flutter widgets for easily adding gaps inside Flex widgets such as Columns and Rows or scrolling views. -1. [sliver_tools](https://github.com/Kavantix) - A set of useful sliver tools that are missing from the flutter framework -1. [html_unescape](https://github.com/filiph/html_unescape) - A small library for un-escaping HTML. Supports all Named Character References, Decimal Character References and Hexadecimal Character References. -1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter -1. [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://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry. -1. [flutter_sharing_intent](https://github.com/bhagat-techind/flutter_sharing_intent.git) - A flutter plugin that allow flutter apps to receive photos, videos, text, urls or any other file types from another app. -1. [flutter_broadcasts](https://pub.dev/packages/flutter_broadcasts) - A plugin for sending and receiving broadcasts with Android intents and iOS notifications. -1. [freezed_annotation](https://pub.dev/packages/freezed_annotation) - Annotations for the freezed code-generator. This package does nothing without freezed too. -1. [spotify](https://github.com/rinukkusu/spotify-dart) - An incomplete dart library for interfacing with the Spotify Web API. -1. [bonsoir](https://bonsoir.skyost.eu) - A Zeroconf library that allows you to discover network services and to broadcast your own. Based on Apple Bonjour and Android NSD. -1. [shelf](https://pub.dev/packages/shelf) - A model for web server middleware that encourages composition and easy reuse. -1. [shelf_router](https://pub.dev/packages/shelf_router) - A convenient request router for the shelf web-framework, with support for URL-parameters, nested routers and routers generated from source annotations. -1. [shelf_web_socket](https://pub.dev/packages/shelf_web_socket) - A shelf handler that wires up a listener for every connection. +1. [visibility_detector](https://pub.dev/packages/visibility_detector) - A widget that detects the visibility of its child and notifies a callback. 1. [web_socket_channel](https://pub.dev/packages/web_socket_channel) - StreamChannel wrappers for WebSockets. Provides a cross-platform WebSocketChannel API, a cross-platform implementation of that API that communicates over an underlying StreamChannel. -1. [lrc](https://pub.dev/packages/lrc) - A Dart-only package that creates, parses, and handles LRC, which is a format that stores song lyrics. -1. [pub_api_client](https://github.com/leoafarias/pub_api_client) - An API Client for Pub to interact with public package information. -1. [pubspec_parse](https://pub.dev/packages/pubspec_parse) - Simple package for parsing pubspec.yaml files with a type-safe API and rich error reporting. -1. [timezone](https://pub.dev/packages/timezone) - Time zone database and time zone aware DateTime. -1. [crypto](https://pub.dev/packages/crypto) - Implementations of SHA, MD5, and HMAC cryptographic functions. -1. [build_runner](https://pub.dev/packages/build_runner) - A build system for Dart code generation and modular compilation. -1. [envied_generator](https://github.com/petercinibulk/envied) - Generator for the Envied package. See https://pub.dev/packages/envied. -1. [flutter_distributor](https://distributor.leanflutter.dev) - A complete tool for packaging and publishing your Flutter apps. -1. [flutter_gen_runner](https://github.com/FlutterGen/flutter_gen) - The Flutter code generator for your assets, fonts, colors, … — Get rid of all String-based APIs. -1. [flutter_launcher_icons](https://github.com/fluttercommunity/flutter_launcher_icons) - A package which simplifies the task of updating your Flutter app's launcher icon. -1. [flutter_lints](https://pub.dev/packages/flutter_lints) - Recommended lints for Flutter apps, packages, and plugins to encourage good coding practices. -1. [hive_generator](https://github.com/hivedb/hive/tree/master/hive_generator) - Extension for Hive. Automatically generates TypeAdapters to store any class. -1. [json_serializable](https://pub.dev/packages/json_serializable) - Automatically generate code for converting to and from JSON by annotating Dart classes. -1. [freezed](https://pub.dev/packages/freezed) - Code generation for immutable classes that has a simple syntax/API without compromising on the features. -1. [custom_lint](https://pub.dev/packages/custom_lint) - Lint rules are a powerful way to improve the maintainability of a project. Custom Lint allows package authors and developers to easily write custom lint rules. -1. [riverpod_lint](https://riverpod.dev) - Riverpod_lint is a developer tool for users of Riverpod, designed to help stop common issues and simplify repetitive tasks. -1. [flutter_desktop_tools](https://github.com/KRTirtho/flutter_desktop_tools) - Essential collection of tools for flutter desktop app development -1. [piped_client](https://github.com/KRTirtho/piped_client) - API Client for piped.video -1. [scrobblenaut](https://github.com/Nebulino/Scrobblenaut) - A deadly simple LastFM API Wrapper for Dart. So deadly simple that it's gonna hit the mark. +1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter +1. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry. +1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window. 1. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter. -1. [draggable_scrollbar](https://github.com/fluttercommunity/flutter-draggable-scrollbar) - A scrollbar that can be dragged for quickly navigation through a vertical list. Additional option is showing label next to scrollthumb with information about current item. -1. [dart_discord_rpc](https://github.com/alexmercerind/dart_discord_rpc) - Discord Rich Presence for Flutter & Dart apps & games. +1. [xml](https://github.com/renggli/dart-xml) - A lightweight library for parsing, traversing, querying, transforming and building XML documents. +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.

© Copyright Spotube 2024

diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 68308ba1..b474ec7e 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -320,5 +320,10 @@ "select": "اختر", "connect_client_alert": "أنت تتم التحكم بواسطة {client}", "this_device": "هذا الجهاز", - "remote": "بعيد" + "remote": "بعيد", + "local_library": "المكتبة المحلية", + "add_library_location": "أضف إلى المكتبة", + "remove_library_location": "إزالة من المكتبة", + "local_tab": "محلي", + "stats": "إحصائيات" } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 506e78bc..2cf8dd43 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -320,5 +320,10 @@ "select": "নির্বাচন করুন", "connect_client_alert": "আপনি {client} দ্বারা নিয়ন্ত্রিত হচ্ছেন", "this_device": "এই ডিভাইস", - "remote": "রিমোট" + "remote": "রিমোট", + "local_library": "স্থানীয় লাইব্রেরি", + "add_library_location": "লাইব্রেরিতে যোগ করুন", + "remove_library_location": "লাইব্রেরি থেকে সরান", + "local_tab": "স্থানীয়", + "stats": "পরিসংখ্যান" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index 8faa0d09..ca4b019a 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -320,5 +320,10 @@ "select": "Selecciona", "connect_client_alert": "Estàs sent controlat per {client}", "this_device": "Aquest dispositiu", - "remote": "Remot" + "remote": "Remot", + "local_library": "Biblioteca local", + "add_library_location": "Afegeix a la biblioteca", + "remove_library_location": "Elimina de la biblioteca", + "local_tab": "Local", + "stats": "Estadístiques" } \ No newline at end of file diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 52f5bcf8..7191c108 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -320,5 +320,10 @@ "select": "Vybrat", "connect_client_alert": "Zařízení je ovládáno z {client}", "this_device": "Toto zařízení", - "remote": "Ovladač" + "remote": "Ovladač", + "local_library": "Místní knihovna", + "add_library_location": "Přidat do knihovny", + "remove_library_location": "Odebrat z knihovny", + "local_tab": "Místní", + "stats": "Statistiky" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 77435d67..c455e08a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -320,5 +320,10 @@ "select": "Auswählen", "connect_client_alert": "Du wirst von {client} gesteuert", "this_device": "Dieses Gerät", - "remote": "Fernbedienung" + "remote": "Fernbedienung", + "local_library": "Lokale Bibliothek", + "add_library_location": "Zur Bibliothek hinzufügen", + "remove_library_location": "Aus der Bibliothek entfernen", + "local_tab": "Lokal", + "stats": "Statistiken" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 11617b42..6558c743 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -320,5 +320,10 @@ "select": "Seleccionar", "connect_client_alert": "Estás siendo controlado por {client}", "this_device": "Este dispositivo", - "remote": "Remoto" + "remote": "Remoto", + "local_library": "Biblioteca local", + "add_library_location": "Añadir a la biblioteca", + "remove_library_location": "Eliminar de la biblioteca", + "local_tab": "Local", + "stats": "Estadísticas" } \ No newline at end of file diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb index 9a4ebb46..fb00a925 100644 --- a/lib/l10n/app_eu.arb +++ b/lib/l10n/app_eu.arb @@ -320,5 +320,10 @@ "select": "Aukeratu", "connect_client_alert": "{client} gailuak kontrolatzen zaitu", "this_device": "Gailu hau", - "remote": "Urrunekoa" + "remote": "Urrunekoa", + "local_library": "Liburutegi lokala", + "add_library_location": "Gehitu liburutegira", + "remove_library_location": "Kendu liburutegitik", + "local_tab": "Tokiko", + "stats": "Estatistikak" } \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 8a0bee3a..b939de59 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -320,5 +320,10 @@ "select": "انتخاب", "connect_client_alert": "شما توسط {client} کنترل می‌شوید", "this_device": "این دستگاه", - "remote": "راه‌دور" + "remote": "راه‌دور", + "local_library": "کتابخانه محلی", + "add_library_location": "اضافه کردن به کتابخانه", + "remove_library_location": "حذف از کتابخانه", + "local_tab": "محلی", + "stats": "آمار" } \ No newline at end of file diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 35470791..d0767e95 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -320,5 +320,10 @@ "select": "Valitse", "connect_client_alert": "{client} ohjaa sinua", "this_device": "Tämä laite", - "remote": "Etä" + "remote": "Etä", + "local_library": "Paikallinen kirjasto", + "add_library_location": "Lisää kirjastoon", + "remove_library_location": "Poista kirjastosta", + "local_tab": "Paikallinen", + "stats": "Tilastot" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index cabcb8e1..6bd2d0f8 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -320,5 +320,10 @@ "select": "Sélectionner", "connect_client_alert": "Vous êtes contrôlé par {client}", "this_device": "Cet appareil", - "remote": "À distance" + "remote": "À distance", + "local_library": "Bibliothèque locale", + "add_library_location": "Ajouter à la bibliothèque", + "remove_library_location": "Retirer de la bibliothèque", + "local_tab": "Local", + "stats": "Statistiques" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a72e136e..7dc809c7 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -320,5 +320,10 @@ "select": "चयन करें", "connect_client_alert": "आप {client} द्वारा नियंत्रित हो रहे हैं", "this_device": "यह उपकरण", - "remote": "रिमोट" + "remote": "रिमोट", + "local_library": "स्थानीय पुस्तकालय", + "add_library_location": "पुस्तकालय में जोड़ें", + "remove_library_location": "पुस्तकालय से हटाएं", + "local_tab": "स्थानीय", + "stats": "आंकड़े" } \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index b94cdd28..669f5e2a 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -320,5 +320,10 @@ "select": "Pilih", "connect_client_alert": "Anda dikendalikan oleh {client}", "this_device": "Perangkat Ini", - "remote": "Remot" + "remote": "Remot", + "local_library": "Perpustakaan lokal", + "add_library_location": "Tambahkan ke perpustakaan", + "remove_library_location": "Hapus dari perpustakaan", + "local_tab": "Lokal", + "stats": "Statistik" } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index bb1881d6..9ba30acc 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -321,5 +321,10 @@ "select": "Seleziona", "connect_client_alert": "Stai venendo controllato da {client}", "this_device": "Questo dispositivo", - "remote": "Remoto" + "remote": "Remoto", + "local_library": "Biblioteca locale", + "add_library_location": "Aggiungi alla biblioteca", + "remove_library_location": "Rimuovi dalla biblioteca", + "local_tab": "Locale", + "stats": "Statistiche" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index ab759404..35e76b69 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -320,5 +320,10 @@ "select": "選択する", "connect_client_alert": "{client} によって操作されています", "this_device": "このデバイス", - "remote": "リモート" + "remote": "リモート", + "local_library": "ローカルライブラリ", + "add_library_location": "ライブラリに追加", + "remove_library_location": "ライブラリから削除", + "local_tab": "ローカル", + "stats": "統計" } \ No newline at end of file diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb index 3da06444..28fcc26a 100644 --- a/lib/l10n/app_ka.arb +++ b/lib/l10n/app_ka.arb @@ -320,5 +320,10 @@ "select": "არჩევა", "connect_client_alert": "თქვენ კონტროლირებული ხართ {client} მოწყობილობით", "this_device": "ეს მოწყობილობა", - "remote": "დისტანციური" + "remote": "დისტანციური", + "local_library": "ადგილობრივი ბიბლიოთეკა", + "add_library_location": "ბიბლიოთეკაში დამატება", + "remove_library_location": "ბიბლიოთეკიდან წაშლა", + "local_tab": "ადგილობრივი", + "stats": "სტატისტიკა" } \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c94f8142..cb6e0999 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -321,5 +321,10 @@ "select": "선택", "connect_client_alert": "{client}님에 의해 제어되고 있습니다", "this_device": "이 장치", - "remote": "원격" + "remote": "원격", + "local_library": "로컬 도서관", + "add_library_location": "도서관에 추가", + "remove_library_location": "도서관에서 제거", + "local_tab": "로컬", + "stats": "통계" } \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb index 4085b00e..f8e8d46a 100644 --- a/lib/l10n/app_ne.arb +++ b/lib/l10n/app_ne.arb @@ -320,5 +320,10 @@ "select": "चयन गर्नुहोस्", "connect_client_alert": "तपाईंलाई {client} द्वारा नियन्त्रित गरिएको छ", "this_device": "यो उपकरण", - "remote": "दूरसंचार" + "remote": "दूरसंचार", + "local_library": "स्थानिय पुस्तकालय", + "add_library_location": "पुस्तकालयमा थप्नुहोस्", + "remove_library_location": "पुस्तकालयबाट हटाउनुहोस्", + "local_tab": "स्थानिय", + "stats": "तथ्याङ्क" } \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 0a04c40b..aa5c846d 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -321,5 +321,10 @@ "select": "Selecteren", "connect_client_alert": "Je wordt gecontroleerd door {client}", "this_device": "Dit apparaat", - "remote": "Afstandsbediening" + "remote": "Afstandsbediening", + "local_library": "Lokale bibliotheek", + "add_library_location": "Toevoegen aan bibliotheek", + "remove_library_location": "Verwijderen uit bibliotheek", + "local_tab": "Lokaal", + "stats": "Statistieken" } \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 9ce31187..2c4e8369 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -320,5 +320,10 @@ "select": "Wybierz", "connect_client_alert": "Jesteś sterowany przez {client}", "this_device": "To urządzenie", - "remote": "Zdalny" + "remote": "Zdalny", + "local_library": "Biblioteka lokalna", + "add_library_location": "Dodaj do biblioteki", + "remove_library_location": "Usuń z biblioteki", + "local_tab": "Lokalny", + "stats": "Statystyki" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 53732589..88cf5cb3 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -320,5 +320,10 @@ "select": "Selecionar", "connect_client_alert": "Você está sendo controlado por {client}", "this_device": "Este dispositivo", - "remote": "Remoto" + "remote": "Remoto", + "local_library": "Biblioteca local", + "add_library_location": "Adicionar à biblioteca", + "remove_library_location": "Remover da biblioteca", + "local_tab": "Local", + "stats": "Estatísticas" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index a18e02e7..0a1c1c22 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -320,5 +320,10 @@ "select": "Выбрать", "connect_client_alert": "Вас контролирует {client}", "this_device": "Это устройство", - "remote": "Дистанционное управление" + "remote": "Дистанционное управление", + "local_library": "Местная библиотека", + "add_library_location": "Добавить в библиотеку", + "remove_library_location": "Удалить из библиотеки", + "local_tab": "Местный", + "stats": "Статистика" } \ No newline at end of file diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 866929fa..60ced74b 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -321,5 +321,10 @@ "select": "เลือก", "connect_client_alert": "คุณกำลังถูกควบคุมโดย {client}", "this_device": "อุปกรณ์นี้", - "remote": "ระยะไกล" + "remote": "ระยะไกล", + "local_library": "ห้องสมุดท้องถิ่น", + "add_library_location": "เพิ่มในห้องสมุด", + "remove_library_location": "ลบออกจากห้องสมุด", + "local_tab": "ท้องถิ่น", + "stats": "สถิติ" } \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index aab6bc6d..b329cfa7 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -320,5 +320,10 @@ "select": "Seç", "connect_client_alert": "{client} tarafından kontrol ediliyorsun.", "this_device": "Bu cihaz", - "remote": "Yönet" + "remote": "Yönet", + "local_library": "Yerel kütüphane", + "add_library_location": "Kütüphaneye ekle", + "remove_library_location": "Kütüphaneden çıkar", + "local_tab": "Yerel", + "stats": "İstatistikler" } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 4208a3d2..d056524e 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -320,5 +320,10 @@ "select": "Вибрати", "connect_client_alert": "Вас керує {client}", "this_device": "Цей пристрій", - "remote": "Віддалений" + "remote": "Віддалений", + "local_library": "Місцева бібліотека", + "add_library_location": "Додати до бібліотеки", + "remove_library_location": "Видалити з бібліотеки", + "local_tab": "Місцевий", + "stats": "Статистика" } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 6115fc0c..6bbd6cb6 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -320,5 +320,10 @@ "select": "Chọn", "connect_client_alert": "Bạn đang được điều khiển bởi {client}", "this_device": "Thiết bị này", - "remote": "Từ xa" + "remote": "Từ xa", + "local_library": "Thư viện địa phương", + "add_library_location": "Thêm vào thư viện", + "remove_library_location": "Xóa khỏi thư viện", + "local_tab": "Địa phương", + "stats": "Thống kê" } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index da5254a3..b145f97b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -320,5 +320,10 @@ "select": "选择", "connect_client_alert": "您正在被 {client} 控制", "this_device": "此设备", - "remote": "远程" + "remote": "远程", + "local_library": "本地图书馆", + "add_library_location": "添加到图书馆", + "remove_library_location": "从图书馆中删除", + "local_tab": "本地", + "stats": "统计" } \ No newline at end of file From 8fc44ed6550e8b2b804991ff82df08afb1c94ca8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 5 Jun 2024 17:59:31 +0600 Subject: [PATCH 102/261] fix(linux): application window not visible after launch --- linux/my_application.cc | 30 +++--- untranslated_messages.json | 210 +------------------------------------ 2 files changed, 18 insertions(+), 222 deletions(-) diff --git a/linux/my_application.cc b/linux/my_application.cc index d1ac5d12..767025ca 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -23,7 +23,7 @@ static void my_application_activate(GApplication* application) { gtk_window_present(GTK_WINDOW(windows->data)); return; } - + GtkWindow* window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); @@ -55,10 +55,11 @@ static void my_application_activate(GApplication* application) { } gtk_window_set_default_size(window, 1280, 720); - gtk_widget_realize(GTK_WIDGET(window)); + gtk_widget_show(GTK_WIDGET(window)); g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); FlView* view = fl_view_new(project); gtk_widget_show(GTK_WIDGET(view)); @@ -70,16 +71,18 @@ static void my_application_activate(GApplication* application) { } // Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { MyApplication* self = MY_APPLICATION(application); // Strip out the first argument as it is the binary name. self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); g_autoptr(GError) error = nullptr; if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; } g_application_activate(application); @@ -97,15 +100,16 @@ static void my_application_dispose(GObject* object) { static void my_application_class_init(MyApplicationClass* klass) { G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; G_OBJECT_CLASS(klass)->dispose = my_application_dispose; } static void my_application_init(MyApplication* self) {} MyApplication* my_application_new() { - return MY_APPLICATION(g_object_new(my_application_get_type(), - "com.github.KRTirtho.Spotube", APPLICATION_ID, - "flags", G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, - nullptr)); -} + return MY_APPLICATION(g_object_new( + my_application_get_type(), "application-id", APPLICATION_ID, "flags", + G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, + nullptr)); +} \ No newline at end of file diff --git a/untranslated_messages.json b/untranslated_messages.json index aaf06929..9e26dfee 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,209 +1 @@ -{ - "ar": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "bn": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ca": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "cs": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "de": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "es": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "eu": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "fa": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "fi": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "fr": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "hi": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "id": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "it": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ja": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ka": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ko": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ne": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "nl": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "pl": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "pt": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ru": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "th": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "tr": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "uk": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "vi": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "zh": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ] -} +{} \ No newline at end of file From 26ee84d990e11789c70bf9bee3ec2f49159a0469 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 5 Jun 2024 22:06:55 +0600 Subject: [PATCH 103/261] chore: remove window_size deps as unused --- linux/flutter/generated_plugin_registrant.cc | 4 ---- linux/flutter/generated_plugins.cmake | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 2 -- pubspec.lock | 9 --------- pubspec.yaml | 5 ----- windows/flutter/generated_plugin_registrant.cc | 3 --- windows/flutter/generated_plugins.cmake | 1 - 7 files changed, 25 deletions(-) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 2f61edd6..e22c5732 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -18,7 +18,6 @@ #include #include #include -#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) dart_discord_rpc_registrar = @@ -57,7 +56,4 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); - g_autoptr(FlPluginRegistrar) window_size_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "WindowSizePlugin"); - window_size_plugin_register_with_registrar(window_size_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 48c7e0ca..9ddc2b98 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -15,7 +15,6 @@ list(APPEND FLUTTER_PLUGIN_LIST tray_manager url_launcher_linux window_manager - window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0057db14..047e7f3d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -25,7 +25,6 @@ import system_tray import tray_manager import url_launcher_macos import window_manager -import window_size func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) @@ -48,5 +47,4 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) - WindowSizePlugin.register(with: registry.registrar(forPlugin: "WindowSizePlugin")) } diff --git a/pubspec.lock b/pubspec.lock index cf72db1c..e1b0f97c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2422,15 +2422,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.9" - window_size: - dependency: "direct main" - description: - path: "plugins/window_size" - ref: a738913c8ce2c9f47515382d40827e794a334274 - resolved-ref: a738913c8ce2c9f47515382d40827e794a334274 - url: "https://github.com/google/flutter-desktop-embedding.git" - source: git - version: "0.1.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c256f66e..60d18bc7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -87,11 +87,6 @@ dependencies: version: ^3.0.2 visibility_detector: ^0.4.0+2 window_manager: ^0.3.9 - window_size: - git: - url: https://github.com/google/flutter-desktop-embedding.git - ref: a738913c8ce2c9f47515382d40827e794a334274 - path: plugins/window_size youtube_explode_dart: ^2.2.1 simple_icons: ^10.1.3 jiosaavn: ^0.1.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index f2dd9714..559db310 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -20,7 +20,6 @@ #include #include #include -#include void RegisterPlugins(flutter::PluginRegistry* registry) { AppLinksPluginCApiRegisterWithRegistrar( @@ -51,6 +50,4 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); - WindowSizePluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("WindowSizePlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index f4e14280..d1464df5 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -17,7 +17,6 @@ list(APPEND FLUTTER_PLUGIN_LIST tray_manager url_launcher_windows window_manager - window_size ) list(APPEND FLUTTER_FFI_PLUGIN_LIST From 57cd8607dd8f32b9c1ca8f9df2eca4715998df9d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 08:19:07 +0600 Subject: [PATCH 104/261] Revert "refactor: migrate deprecated warnings" chore: undo flutter 3.22.x related deprecated theme migrations --- lib/components/artist/artist_card.dart | 12 ++++----- .../home/sections/friends/friend_item.dart | 2 +- lib/components/home/sections/genres.dart | 2 +- .../local_folder/local_folder_item.dart | 2 +- .../playlist_generate/multi_select_field.dart | 2 +- lib/components/player/player_queue.dart | 3 +-- .../player/sibling_tracks_sheet.dart | 3 +-- lib/components/root/bottom_player.dart | 11 +++++--- lib/components/root/sidebar.dart | 10 +++++-- .../root/spotube_navigation_bar.dart | 2 +- .../settings/color_scheme_picker_dialog.dart | 4 +-- .../adaptive/adaptive_pop_sheet_list.dart | 2 +- .../shared/links/anchor_button.dart | 2 +- .../shared/page_window_title_bar.dart | 12 ++++----- lib/components/shared/playbutton_card.dart | 14 +++++----- .../shared/themed_button_tab_bar.dart | 2 +- lib/components/stats/common/album_item.dart | 2 +- lib/main.dart | 23 +++++++++++++--- lib/pages/desktop_login/desktop_login.dart | 2 +- lib/pages/lyrics/lyrics.dart | 2 +- lib/pages/lyrics/mini_lyrics.dart | 12 +++++---- lib/pages/search/search.dart | 6 ++--- lib/pages/settings/blacklist.dart | 1 + lib/pages/settings/sections/about.dart | 7 ++--- lib/pages/settings/sections/accounts.dart | 2 +- lib/pages/settings/sections/downloads.dart | 1 + lib/themes/theme.dart | 27 +++++++------------ 27 files changed, 96 insertions(+), 74 deletions(-) diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 9c1ee14a..57971ada 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -35,10 +35,6 @@ class ArtistCard extends HookConsumerWidget { final radius = BorderRadius.circular(15); - final bgColor = useBrightnessValue( - theme.colorScheme.surface, - theme.colorScheme.surfaceContainerHigh, - ); final double size = useBreakpointValue( xs: 130, sm: 130, @@ -50,8 +46,12 @@ class ArtistCard extends HookConsumerWidget { width: size, margin: const EdgeInsets.symmetric(vertical: 5), child: Material( - shadowColor: theme.colorScheme.surface, - color: bgColor, + shadowColor: theme.colorScheme.background, + color: Color.lerp( + theme.colorScheme.surfaceVariant, + theme.colorScheme.surface, + useBrightnessValue(.9, .7), + ), elevation: 3, shape: RoundedRectangleBorder( borderRadius: radius, diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart index 096964a6..2b575756 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/components/home/sections/friends/friend_item.dart @@ -30,7 +30,7 @@ class FriendItem extends HookConsumerWidget { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surfaceContainer, + color: colorScheme.surfaceVariant.withOpacity(0.3), borderRadius: BorderRadius.circular(15), ), constraints: const BoxConstraints( diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index 62f462e2..7dfafd5a 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -134,7 +134,7 @@ class HomeGenresSection extends HookConsumerWidget { child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), - color: colorScheme.surfaceContainerHighest, + color: colorScheme.surfaceVariant, gradient: categoriesQuery.isLoading ? null : gradient, ), padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/components/library/local_folder/local_folder_item.dart index 6220a967..72032198 100644 --- a/lib/components/library/local_folder/local_folder_item.dart +++ b/lib/components/library/local_folder/local_folder_item.dart @@ -71,7 +71,7 @@ class LocalFolderItem extends HookConsumerWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Color.lerp( - colorScheme.surfaceContainerHighest, + colorScheme.surfaceVariant, colorScheme.surface, lerpValue, ), diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/components/library/playlist_generate/multi_select_field.dart index d8e0506d..e54fc2ba 100644 --- a/lib/components/library/playlist_generate/multi_select_field.dart +++ b/lib/components/library/playlist_generate/multi_select_field.dart @@ -71,7 +71,7 @@ class MultiSelectField extends HookWidget { : theme.colorScheme.onSurface.withOpacity(0.1), ), ), - mouseCursor: WidgetStateMouseCursor.textable, + mouseCursor: MaterialStateMouseCursor.textable, onPressed: !enabled ? null : () async { diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 1665b3dd..914d7bc9 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -122,8 +122,7 @@ class PlayerQueue extends HookConsumerWidget { top: 5.0, ), decoration: BoxDecoration( - color: - theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), + color: theme.colorScheme.surfaceVariant.withOpacity(0.5), borderRadius: borderRadius, ), child: CallbackShortcuts( diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 0575d8eb..99b7b430 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -208,8 +208,7 @@ class SiblingTracksSheet extends HookConsumerWidget { : mediaQuery.size.height * .6, decoration: BoxDecoration( borderRadius: borderRadius, - color: - theme.colorScheme.surfaceContainerHighest.withOpacity(.5), + color: theme.colorScheme.surfaceVariant.withOpacity(.5), ), child: Scaffold( backgroundColor: Colors.transparent, diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index b99318df..5429e172 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; @@ -48,6 +49,12 @@ class BottomPlayer extends HookConsumerWidget { ); final theme = Theme.of(context); + final bg = theme.colorScheme.surfaceVariant; + + final bgColor = useBrightnessValue( + Color.lerp(bg, Colors.white, 0.7), + Color.lerp(bg, Colors.black, 0.45)!, + ); // returning an empty non spacious Container as the overlay will take // place in the global overlay stack aka [_entries] @@ -60,9 +67,7 @@ class BottomPlayer extends HookConsumerWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer.withOpacity(.8), - ), + decoration: BoxDecoration(color: bgColor?.withOpacity(0.8)), child: Material( type: MaterialType.transparency, textStyle: theme.textTheme.bodyMedium!, diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 4fa14021..0e644a89 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/pages/settings/settings.dart'; @@ -69,7 +70,12 @@ class Sidebar extends HookConsumerWidget { ); final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceContainer; + final bg = theme.colorScheme.surfaceVariant; + + final bgColor = useBrightnessValue( + Color.lerp(bg, Colors.white, 0.7), + Color.lerp(bg, Colors.black, 0.45)!, + ); useEffect(() { if (!context.mounted) return; @@ -153,7 +159,7 @@ class Sidebar extends HookConsumerWidget { ), padding: const EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( - color: bg, + color: bgColor?.withOpacity(0.8), borderRadius: const BorderRadius.only( topRight: Radius.circular(10), bottomRight: Radius.circular(10), diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 3d0c7c75..e16ad1a8 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -68,7 +68,7 @@ class SpotubeNavigationBar extends HookConsumerWidget { backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.72), buttonBackgroundColor: buttonColor, - color: theme.colorScheme.surface, + color: theme.colorScheme.background, height: panelHeight, animationDuration: const Duration(milliseconds: 350), items: navbarTileList.map( diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart index 579f5a29..8d098375 100644 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ b/lib/components/settings/color_scheme_picker_dialog.dart @@ -179,9 +179,9 @@ class ColorTile extends StatelessWidget { colorScheme.primaryContainer, colorScheme.secondary, colorScheme.secondaryContainer, + colorScheme.background, colorScheme.surface, - colorScheme.surface, - colorScheme.surfaceContainerHighest, + colorScheme.surfaceVariant, colorScheme.onPrimary, colorScheme.onSurface, ]; diff --git a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart index ce7d3b8c..21f56a22 100644 --- a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart @@ -187,7 +187,7 @@ class AdaptivePopSheetList extends StatelessWidget { icon: icon ?? const Icon(SpotubeIcons.moreVertical), tooltip: tooltip, style: theme.iconButtonTheme.style?.copyWith( - shape: WidgetStatePropertyAll( + shape: MaterialStatePropertyAll( RoundedRectangleBorder( borderRadius: borderRadius, ), diff --git a/lib/components/shared/links/anchor_button.dart b/lib/components/shared/links/anchor_button.dart index c6f0b889..d78bbf96 100644 --- a/lib/components/shared/links/anchor_button.dart +++ b/lib/components/shared/links/anchor_button.dart @@ -29,7 +29,7 @@ class AnchorButton extends HookWidget { onTapUp: (event) => tap.value = false, onTap: onTap, child: MouseRegion( - cursor: WidgetStateMouseCursor.clickable, + cursor: MaterialStateMouseCursor.clickable, child: Text( text, style: style.copyWith( diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index 573c7c47..d7c8320d 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -210,16 +210,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { final theme = Theme.of(context); final colors = WindowButtonColors( normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onSurface, - mouseOver: theme.colorScheme.onSurface.withOpacity(0.1), - mouseDown: theme.colorScheme.onSurface.withOpacity(0.2), - iconMouseOver: theme.colorScheme.onSurface, - iconMouseDown: theme.colorScheme.onSurface, + iconNormal: foregroundColor ?? theme.colorScheme.onBackground, + mouseOver: theme.colorScheme.onBackground.withOpacity(0.1), + mouseDown: theme.colorScheme.onBackground.withOpacity(0.2), + iconMouseOver: theme.colorScheme.onBackground, + iconMouseDown: theme.colorScheme.onBackground, ); final closeColors = WindowButtonColors( normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onSurface, + iconNormal: foregroundColor ?? theme.colorScheme.onBackground, mouseOver: Colors.red, mouseDown: Colors.red[800]!, iconMouseOver: Colors.white, diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index 807628b3..80a27eb0 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -53,10 +53,6 @@ class PlaybuttonCard extends HookWidget { final mediaQuery = MediaQuery.of(context); final radius = BorderRadius.circular(15); - final bgColor = useBrightnessValue( - theme.colorScheme.surface, - theme.colorScheme.surfaceContainerHigh, - ); final double size = useBreakpointValue( xs: 130, sm: 130, @@ -76,9 +72,13 @@ class PlaybuttonCard extends HookWidget { constraints: BoxConstraints(maxWidth: size), margin: margin, child: Material( - color: bgColor, + color: Color.lerp( + theme.colorScheme.surfaceVariant, + theme.colorScheme.surface, + useBrightnessValue(.9, .7), + ), borderRadius: radius, - shadowColor: theme.colorScheme.surface, + shadowColor: theme.colorScheme.background, elevation: 3, child: InkWell( mouseCursor: SystemMouseCursors.click, @@ -158,7 +158,7 @@ class PlaybuttonCard extends HookWidget { Skeleton.keep( child: IconButton( style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.surface, + backgroundColor: theme.colorScheme.background, foregroundColor: theme.colorScheme.primary, minimumSize: const Size.square(10), ), diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index c245e5f4..b21ca992 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -34,7 +34,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { ), borderWidth: 0, unselectedDecoration: BoxDecoration( - color: theme.colorScheme.surface, + color: theme.colorScheme.background, borderRadius: BorderRadius.circular(15), ), unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith( diff --git a/lib/components/stats/common/album_item.dart b/lib/components/stats/common/album_item.dart index 00b1cbfe..ccc0fa4e 100644 --- a/lib/components/stats/common/album_item.dart +++ b/lib/components/stats/common/album_item.dart @@ -33,7 +33,7 @@ class StatsAlbumItem extends StatelessWidget { Text("${album.albumType?.formatted} • "), Flexible( child: ArtistLink( - artists: album.artists ?? [], + artists: album.artists!, mainAxisAlignment: WrapAlignment.start, ), ), diff --git a/lib/main.dart b/lib/main.dart index 1693d9d8..52d0b141 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:local_notifier/local_notifier.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'; @@ -138,11 +139,28 @@ Future main(List rawArgs) async { ); } -class Spotube extends HookConsumerWidget { +class Spotube extends StatefulHookConsumerWidget { const Spotube({super.key}); @override - Widget build(BuildContext context, ref) { + SpotubeState createState() => SpotubeState(); + + static SpotubeState of(BuildContext context) => + context.findAncestorStateOfType()!; +} + +class SpotubeState extends ConsumerState { + final logger = getLogger(Spotube); + SharedPreferences? localStorage; + + @override + void initState() { + super.initState(); + SharedPreferences.getInstance().then(((value) => localStorage = value)); + } + + @override + Widget build(BuildContext context) { final themeMode = ref.watch(userPreferencesProvider.select((s) => s.themeMode)); final accentMaterialColor = @@ -177,7 +195,6 @@ class Spotube extends HookConsumerWidget { () => theme(paletteColor ?? accentMaterialColor, Brightness.light, false), [paletteColor, accentMaterialColor], ); - final darkTheme = useMemoized( () => theme( paletteColor ?? accentMaterialColor, diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index c9367e05..9c9bdddb 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -17,7 +17,7 @@ class DesktopLoginPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final mediaQuery = MediaQuery.of(context); final theme = Theme.of(context); - final color = theme.colorScheme.surfaceContainerHighest.withOpacity(.3); + final color = theme.colorScheme.surfaceVariant.withOpacity(.3); return SafeArea( child: Scaffold( diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 1d9b383a..850eccfa 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -100,7 +100,7 @@ class LyricsPage extends HookConsumerWidget { child: Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withOpacity(.4), + color: Theme.of(context).colorScheme.background.withOpacity(.4), borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index a026209c..996e190d 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -107,7 +107,8 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.lyricsOff), style: ButtonStyle( foregroundColor: showLyrics.value - ? WidgetStateProperty.all(theme.colorScheme.primary) + ? MaterialStateProperty.all( + theme.colorScheme.primary) : null, ), onPressed: () async { @@ -131,7 +132,8 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.hoverOff), style: ButtonStyle( foregroundColor: hoverMode.value - ? WidgetStateProperty.all(theme.colorScheme.primary) + ? MaterialStateProperty.all( + theme.colorScheme.primary) : null, ), onPressed: () async { @@ -152,7 +154,7 @@ class MiniLyricsPage extends HookConsumerWidget { ), style: ButtonStyle( foregroundColor: snapshot.data == true - ? WidgetStateProperty.all( + ? MaterialStateProperty.all( theme.colorScheme.primary) : null, ), @@ -184,12 +186,12 @@ class MiniLyricsPage extends HookConsumerWidget { child: TabBarView( children: [ SyncedLyrics( - palette: PaletteColor(theme.colorScheme.surface, 0), + palette: PaletteColor(theme.colorScheme.background, 0), isModal: true, defaultTextZoom: 65, ), PlainLyrics( - palette: PaletteColor(theme.colorScheme.surface, 0), + palette: PaletteColor(theme.colorScheme.background, 0), isModal: true, defaultTextZoom: 65, ), diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 50ef152b..d5374786 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -212,7 +212,7 @@ class SearchPage extends HookConsumerWidget { Icon( SpotubeIcons.web, size: 120, - color: theme.colorScheme.onSurface + color: theme.colorScheme.onBackground .withOpacity(0.7), ), const SizedBox(height: 20), @@ -220,7 +220,7 @@ class SearchPage extends HookConsumerWidget { context.l10n.search_to_get_results, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w900, - color: theme.colorScheme.onSurface + color: theme.colorScheme.onBackground .withOpacity(0.5), ), ), @@ -246,7 +246,7 @@ class SearchPage extends HookConsumerWidget { style: TextStyle( fontSize: 20, fontWeight: FontWeight.w900, - color: theme.colorScheme.onSurface + color: theme.colorScheme.onBackground .withOpacity(0.7), ), ), diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 4e937922..6eccab07 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -20,6 +20,7 @@ class BlackListPage extends HookConsumerWidget { final controller = useScrollController(); final blacklist = ref.watch(blacklistProvider); final searchText = useState(""); + final filteredBlacklist = useMemoized( () { if (searchText.value.isEmpty) { diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index 5e5d2377..a8d72cc0 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -43,9 +43,10 @@ class SettingsAboutSection extends HookConsumerWidget { ), trailing: (context, update) => FilledButton( style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.red[100]), - foregroundColor: const WidgetStatePropertyAll(Colors.pinkAccent), - padding: const WidgetStatePropertyAll(EdgeInsets.all(15)), + backgroundColor: MaterialStatePropertyAll(Colors.red[100]), + foregroundColor: + const MaterialStatePropertyAll(Colors.pinkAccent), + padding: const MaterialStatePropertyAll(EdgeInsets.all(15)), ), onPressed: () { launchUrlString( diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 5acab480..6162aa3d 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -82,7 +82,7 @@ class SettingsAccountSection extends HookConsumerWidget { router.push("/login"); }, style: ButtonStyle( - shape: WidgetStateProperty.all( + shape: MaterialStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(25.0), ), diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index 76ef8e3e..3092ed03 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -3,6 +3,7 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 28acc280..8659cf0c 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -4,22 +4,13 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { final scheme = ColorScheme.fromSeed( seedColor: seed, shadow: Colors.black12, + background: isAmoled ? Colors.black : null, surface: isAmoled ? Colors.black : null, - surfaceContainer: isAmoled ? const Color(0xFF090909) : null, - surfaceContainerHigh: isAmoled ? const Color(0xFF181818) : null, - surfaceContainerHighest: isAmoled ? const Color(0xFF282828) : null, brightness: brightness, ); return ThemeData( useMaterial3: true, colorScheme: scheme, - scaffoldBackgroundColor: isAmoled ? Colors.black : null, - cardTheme: CardTheme( - color: scheme.surfaceContainer, - shadowColor: scheme.shadow, - elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), - ), listTileTheme: ListTileThemeData( horizontalTitleGap: 5, iconColor: scheme.onSurface, @@ -39,7 +30,7 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { navigationBarTheme: const NavigationBarThemeData( labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, height: 50, - iconTheme: WidgetStatePropertyAll( + iconTheme: MaterialStatePropertyAll( IconThemeData(size: 18), ), ), @@ -66,25 +57,25 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { ), sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay), searchBarTheme: SearchBarThemeData( - textStyle: const WidgetStatePropertyAll(TextStyle(fontSize: 15)), + textStyle: const MaterialStatePropertyAll(TextStyle(fontSize: 15)), constraints: const BoxConstraints(maxWidth: double.infinity), - padding: const WidgetStatePropertyAll(EdgeInsets.all(8)), - backgroundColor: WidgetStatePropertyAll( + padding: const MaterialStatePropertyAll(EdgeInsets.all(8)), + backgroundColor: MaterialStatePropertyAll( Color.lerp( - scheme.surfaceContainerHighest, + scheme.surfaceVariant, scheme.surface, brightness == Brightness.light ? .9 : .7, ), ), - elevation: const WidgetStatePropertyAll(0), - shape: WidgetStatePropertyAll( + elevation: const MaterialStatePropertyAll(0), + shape: MaterialStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), ), scrollbarTheme: const ScrollbarThemeData( - thickness: WidgetStatePropertyAll(14), + thickness: MaterialStatePropertyAll(14), ), checkboxTheme: CheckboxThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), From 982cf0bd435638fa20f17ef527fe21d031b5ffaf Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 08:28:18 +0600 Subject: [PATCH 105/261] fix(windows): revert Flutter version to 3.19.6 to avoid distortion #1553 --- .fvm/fvm_config.json | 2 +- .github/workflows/pr-lint.yml | 2 +- .github/workflows/spotube-release-binary.yml | 2 +- lib/main.dart | 22 ++------------------ macos/Podfile.lock | 6 ------ 5 files changed, 5 insertions(+), 29 deletions(-) diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index df8efa0e..0b54542f 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.22.1", + "flutterSdkVersion": "3.19.6", "flavors": {} } \ No newline at end of file diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index 2844986d..64cc8adc 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,7 +4,7 @@ on: pull_request: env: - FLUTTER_VERSION: '3.22.1' + FLUTTER_VERSION: '3.19.6' jobs: lint: diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 8e68211c..5e32ee70 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -20,7 +20,7 @@ on: description: Dry run without uploading to release env: - FLUTTER_VERSION: 3.22.1 + FLUTTER_VERSION: 3.19.6 permissions: contents: write diff --git a/lib/main.dart b/lib/main.dart index 52d0b141..30526bc6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:local_notifier/local_notifier.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'; @@ -139,28 +138,11 @@ Future main(List rawArgs) async { ); } -class Spotube extends StatefulHookConsumerWidget { +class Spotube extends HookConsumerWidget { const Spotube({super.key}); @override - SpotubeState createState() => SpotubeState(); - - static SpotubeState of(BuildContext context) => - context.findAncestorStateOfType()!; -} - -class SpotubeState extends ConsumerState { - final logger = getLogger(Spotube); - SharedPreferences? localStorage; - - @override - void initState() { - super.initState(); - SharedPreferences.getInstance().then(((value) => localStorage = value)); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, ref) { final themeMode = ref.watch(userPreferencesProvider.select((s) => s.themeMode)); final accentMaterialColor = diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 166bfa71..fcba2934 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -49,8 +49,6 @@ PODS: - FlutterMacOS - window_manager (0.2.0): - FlutterMacOS - - window_size (0.0.2): - - FlutterMacOS DEPENDENCIES: - app_links (from `Flutter/ephemeral/.symlinks/plugins/app_links/macos`) @@ -76,7 +74,6 @@ DEPENDENCIES: - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) - - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) SPEC REPOS: trunk: @@ -129,8 +126,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos window_manager: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos - window_size: - :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a @@ -157,7 +152,6 @@ SPEC CHECKSUMS: tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 - window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0 From 73c5b30b63a4c82bb7ad5b52bc10c5594566a800 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 08:43:29 +0600 Subject: [PATCH 106/261] fix: browse anonymously button takes to wrong route --- lib/pages/getting_started/sections/support.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index 7bccfe06..b6be07e5 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -106,7 +106,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.go(HomePage.name); + context.goNamed(HomePage.name); } }, ), From 37d002d133cacb3a34884713ac8f6637694af57c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 08:47:07 +0600 Subject: [PATCH 107/261] fix: alternative sources not showing up for SongLink matched results --- lib/services/sourced_track/sources/youtube.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index c24edfc0..af61a882 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -221,7 +221,10 @@ class YoutubeSourcedTrack extends SourcedTrack { final links = await SongLinkService.links(track.id!); final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube"); - if (ytLink?.url != null) { + if (ytLink?.url != null + // allows to fetch siblings more results for already sourced track + && + track is! SourcedTrack) { try { return [ await toSiblingType( From 6cb29868d2030b5e9312863c17e8f24889942e24 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 09:07:07 +0600 Subject: [PATCH 108/261] fix: use weak match for Jiosaavn fallback to improve matching --- lib/services/sourced_track/sourced_track.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index 7eedfad8..fc3f2e50 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -138,7 +138,7 @@ abstract class SourcedTrack extends Track { return await JioSaavnSourcedTrack.fetchFromTrack( track: track, ref: ref, - weakMatch: preferences.audioSource == AudioSource.jiosaavn, + weakMatch: true, ); } rethrow; From 3394c1b0574e44dc624b2b2f0bf32f343ae9f049 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 10:46:20 +0600 Subject: [PATCH 109/261] fix(android): Media Controls not working above Android 14 #1561 --- android/app/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 589e22ff..64c32e28 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + From d7d864ff2bc937675a544a7edf645c5148ec836a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 12:29:20 +0600 Subject: [PATCH 110/261] fix(windows): media controls not showing up #1542 --- pubspec.lock | 32 ++++++++++++++++---------------- pubspec.yaml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index e1b0f97c..da410958 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1242,10 +1242,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.18.1" introduction_screen: dependency: "direct main" description: @@ -1298,26 +1298,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.0" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "2.0.1" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "2.0.1" lints: dependency: transitive description: @@ -1458,10 +1458,10 @@ packages: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.0" metadata_god: dependency: "direct main" description: @@ -1984,10 +1984,10 @@ packages: dependency: "direct main" description: name: smtc_windows - sha256: "799bbe0f8e4436da852c5dcc0be482c97b8ae0f504f65c6b750cd239b4835aa0" + sha256: "0fd64d0c6a0c8ea4ea7908d31195eadc8f6d45d5245159fc67259e9e8704100f" url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.3" source_gen: dependency: transitive description: @@ -2146,10 +2146,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.6.1" time: dependency: transitive description: @@ -2354,10 +2354,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "13.0.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 60d18bc7..247a9770 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -78,7 +78,7 @@ dependencies: sidebarx: ^0.17.1 shared_preferences: ^2.2.3 skeleton_text: ^3.0.1 - smtc_windows: ^0.1.2 + smtc_windows: ^0.1.3 stroke_text: ^0.0.2 system_theme: ^2.1.0 titlebar_buttons: ^1.0.0 From 6591ec0e1b441dd8fd535eace19a58c7749389ca Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 13:08:50 +0600 Subject: [PATCH 111/261] fix(ios): download not working #1575 --- .../download_manager/download_manager.dart | 2 ++ pubspec.lock | 28 +++++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart index dbb96791..54d35b02 100644 --- a/lib/services/download_manager/download_manager.dart +++ b/lib/services/download_manager/download_manager.dart @@ -80,6 +80,8 @@ class DownloadManager { logger.d("[DownloadManager] $url"); final file = File(savePath.toString()); + await Directory(path.dirname(savePath)).create(recursive: true); + final tmpDirPath = await Directory( path.join( (await getTemporaryDirectory()).path, diff --git a/pubspec.lock b/pubspec.lock index da410958..c11577f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1242,10 +1242,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.19.0" introduction_screen: dependency: "direct main" description: @@ -1298,26 +1298,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -1458,10 +1458,10 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" metadata_god: dependency: "direct main" description: @@ -2146,10 +2146,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" time: dependency: transitive description: @@ -2354,10 +2354,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" watcher: dependency: transitive description: From 5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 13:41:02 +0600 Subject: [PATCH 112/261] fix(desktop): titlebar drag to move not working --- lib/components/root/sidebar.dart | 35 ++++++++++--------- .../shared/page_window_title_bar.dart | 10 ++++-- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 0e644a89..8e7374b1 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -26,6 +26,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; +import 'package:window_manager/window_manager.dart'; class Sidebar extends HookConsumerWidget { final Widget child; @@ -207,22 +208,24 @@ class SidebarHeader extends HookWidget { ); } - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - children: [ - if (kIsMacOS) const SizedBox(height: 25), - Row( - children: [ - Sidebar.brandLogo(), - const SizedBox(width: 10), - Text( - "Spotube", - style: theme.textTheme.titleLarge, - ), - ], - ), - ], + return DragToMoveArea( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + if (kIsMacOS) const SizedBox(height: 25), + Row( + children: [ + Sidebar.brandLogo(), + const SizedBox(width: 10), + Text( + "Spotube", + style: theme.textTheme.titleLarge, + ), + ], + ), + ], + ), ), ); } diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index d7c8320d..c5fc11e7 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -125,7 +125,10 @@ class _PageWindowTitleBarState extends ConsumerState { leadingWidth: widget.leadingWidth, toolbarTextStyle: widget.toolbarTextStyle, titleTextStyle: widget.titleTextStyle, - title: widget.title, + title: SizedBox( + width: double.infinity, // workaround to force dragging + child: widget.title ?? const Text(""), + ), pinned: widget.pinned, floating: widget.floating, snap: widget.snap, @@ -164,7 +167,10 @@ class _PageWindowTitleBarState extends ConsumerState { leadingWidth: widget.leadingWidth, toolbarTextStyle: widget.toolbarTextStyle, titleTextStyle: widget.titleTextStyle, - title: widget.title, + title: SizedBox( + width: double.infinity, // workaround to force dragging + child: widget.title ?? const Text(""), + ), scrolledUnderElevation: 0, shadowColor: Colors.transparent, forceMaterialTransparency: true, From 47f98b98aafab9b426733ed44cab2be8a646a98e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 13:48:52 +0600 Subject: [PATCH 113/261] fix(desktop): window is not centered --- lib/services/wm_tools/wm_tools.dart | 1 + pubspec.lock | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/lib/services/wm_tools/wm_tools.dart b/lib/services/wm_tools/wm_tools.dart index 4572a8b4..920e09b5 100644 --- a/lib/services/wm_tools/wm_tools.dart +++ b/lib/services/wm_tools/wm_tools.dart @@ -44,6 +44,7 @@ class WindowManagerTools with WidgetsBindingObserver { backgroundColor: Colors.transparent, minimumSize: Size(300, 700), titleBarStyle: TitleBarStyle.hidden, + center: true, ), () async { final savedSize = KVStoreService.windowSize; diff --git a/pubspec.lock b/pubspec.lock index c11577f2..da410958 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1242,10 +1242,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.18.1" introduction_screen: dependency: "direct main" description: @@ -1298,26 +1298,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.0" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "2.0.1" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "2.0.1" lints: dependency: transitive description: @@ -1458,10 +1458,10 @@ packages: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.0" metadata_god: dependency: "direct main" description: @@ -2146,10 +2146,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.6.1" time: dependency: transitive description: @@ -2354,10 +2354,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "13.0.0" watcher: dependency: transitive description: From 6c6488ea6da9c1e0bccf5fe501e5574c1a8f937c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 14:36:14 +0600 Subject: [PATCH 114/261] cd: fix version not being extracted in the steps --- .github/workflows/spotube-release-binary.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 5e32ee70..e99aebab 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -117,6 +117,7 @@ jobs: needs: - build_platform steps: + - uses: actions/checkout@v4 - uses: actions/download-artifact@v3 with: name: Spotube-Release-Binaries From bf769f473b169f5eb9af28d36fc05b1709df03fd Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 14:52:28 +0600 Subject: [PATCH 115/261] chore: bump version, generate changelog and credits --- .github/workflows/spotube-publish-binary.yml | 2 +- CHANGELOG.md | 17 +++++++++++++++++ README.md | 1 - pubspec.yaml | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 0d39ab1d..7f85173f 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.7.0 + default: 3.7.1 required: true dry_run: description: Dry run diff --git a/CHANGELOG.md b/CHANGELOG.md index 21fb79d4..22919a32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ 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.7.1](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.7.1) (2024-06-06) + + +### Bug Fixes + +* alternative sources not showing up for SongLink matched results ([37d002d](https://github.com/krtirtho/spotube/commit/37d002d133cacb3a34884713ac8f6637694af57c)) +* **android:** Media Controls not working above Android 14 [#1561](https://github.com/krtirtho/spotube/issues/1561) ([3394c1b](https://github.com/krtirtho/spotube/commit/3394c1b0574e44dc624b2b2f0bf32f343ae9f049)) +* browse anonymously button takes to wrong route ([73c5b30](https://github.com/krtirtho/spotube/commit/73c5b30b63a4c82bb7ad5b52bc10c5594566a800)) +* **desktop:** titlebar drag to move not working ([5f280a1](https://github.com/krtirtho/spotube/commit/5f280a19f4d5f8882ae2ff60c6cc595ded7a5a1d)) +* **desktop:** window is not centered ([47f98b9](https://github.com/krtirtho/spotube/commit/47f98b98aafab9b426733ed44cab2be8a646a98e)) +* **ios:** download not working [#1575](https://github.com/krtirtho/spotube/issues/1575) ([6591ec0](https://github.com/krtirtho/spotube/commit/6591ec0e1b441dd8fd535eace19a58c7749389ca)) +* **linux:** application window not visible after launch ([8fc44ed](https://github.com/krtirtho/spotube/commit/8fc44ed6550e8b2b804991ff82df08afb1c94ca8)) +* local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) +* use weak match for Jiosaavn fallback to improve matching ([6cb2986](https://github.com/krtirtho/spotube/commit/6cb29868d2030b5e9312863c17e8f24889942e24)) +* **windows:** media controls not showing up [#1542](https://github.com/krtirtho/spotube/issues/1542) ([d7d864f](https://github.com/krtirtho/spotube/commit/d7d864ff2bc937675a544a7edf645c5148ec836a)) +* **windows:** revert Flutter version to 3.19.6 to avoid distortion [#1553](https://github.com/krtirtho/spotube/issues/1553) ([982cf0b](https://github.com/krtirtho/spotube/commit/982cf0bd435638fa20f17ef527fe21d031b5ffaf)) + ## [3.7.0](https://github.com/krtirtho/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) diff --git a/README.md b/README.md index 5db4d5ad..71c879ba 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,6 @@ If you are concerned, you can [read the reason of choosing this license](https:/ 1. [wikipedia_api](https://github.com/KRTirtho/wikipedia_api) - Wikipedia API for dart and flutter 1. [win32_registry](https://pub.dev/packages/win32_registry) - A package that provides a friendly Dart API for accessing the Windows Registry. 1. [window_manager](https://github.com/leanflutter/window_manager) - This plugin allows Flutter desktop apps to resizing and repositioning the window. -1. [window_size](https://github.com/google/flutter-desktop-embedding.git) - Allows resizing and repositioning the window containing Flutter. 1. [xml](https://github.com/renggli/dart-xml) - A lightweight library for parsing, traversing, querying, transforming and building XML documents. 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. diff --git a/pubspec.yaml b/pubspec.yaml index 247a9770..ffa8511f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.7.0+31 +version: 3.7.1+32 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube From a9c78b786343bdc744745d884e59601a5834fd91 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 14:56:08 +0600 Subject: [PATCH 116/261] Revert "chore: Release v3.7.0 (#1552)" This reverts commit 3aca7372af8ae1b62a2ad657341331a5d310d678. --- .dockerignore | 6 - .env.example | 3 - .fvm/fvm_config.json | 2 +- .github/Dockerfile | 23 - .github/Dockerfile.flutter_distributor | 23 - .github/workflows/pr-lint.yml | 2 +- .github/workflows/spotube-publish-binary.yml | 2 +- .github/workflows/spotube-release-binary.yml | 456 +++++++++-- .metadata | 16 +- .vscode/settings.json | 1 - CHANGELOG.md | 34 +- android/app/build.gradle | 30 +- android/app/src/main/AndroidManifest.xml | 5 - android/build.gradle | 13 + android/settings.gradle | 30 +- bin/gen-credits.dart | 103 +++ bin/translated_messages.dart | 28 + bin/untranslated_messages.dart | 50 ++ bin/verify-pkgbuild.dart | 22 + build.yaml | 7 +- cli/README.md | 4 - cli/cli.dart | 22 - cli/commands/build.dart | 25 - cli/commands/build/android.dart | 90 --- cli/commands/build/common.dart | 66 -- cli/commands/build/ios.dart | 29 - cli/commands/build/linux.dart | 106 --- cli/commands/build/linux_arm.dart | 37 - cli/commands/build/macos.dart | 42 - cli/commands/build/windows.dart | 100 --- cli/commands/credits.dart | 121 --- cli/commands/install-dependencies.dart | 74 -- cli/commands/translated.dart | 39 - cli/commands/untranslated.dart | 48 -- cli/core/env.dart | 24 - devtools_options.yaml | 1 - ios/Podfile.lock | 43 +- ios/Runner.xcodeproj/project.pbxproj | 72 -- lib/collections/env.dart | 18 +- lib/collections/fake.dart | 1 + lib/collections/formatters.dart | 8 - lib/collections/initializers.dart | 5 +- lib/collections/intents.dart | 12 +- lib/collections/language_codes.dart | 32 +- lib/collections/routes.dart | 122 +-- lib/collections/side_bar_tiles.dart | 71 +- lib/collections/spotube_icons.dart | 3 - lib/components/album/album_card.dart | 18 +- lib/components/artist/artist_card.dart | 21 +- lib/components/connect/connect_device.dart | 7 +- lib/components/desktop_login/login_form.dart | 3 +- lib/components/home/sections/feed.dart | 10 +- lib/components/home/sections/friends.dart | 47 +- .../home/sections/friends/friend_item.dart | 24 +- lib/components/home/sections/genres.dart | 16 +- lib/components/home/sections/recent.dart | 32 - .../local_folder/local_folder_item.dart | 199 ----- .../playlist_generate/multi_select_field.dart | 2 +- lib/components/library/user_local_tracks.dart | 364 +++++++-- lib/components/player/player_queue.dart | 3 +- .../player/sibling_tracks_sheet.dart | 3 +- lib/components/player/volume_slider.dart | 17 +- lib/components/playlist/playlist_card.dart | 57 +- lib/components/root/bottom_player.dart | 29 +- lib/components/root/sidebar.dart | 101 +-- .../root/spotube_navigation_bar.dart | 42 +- lib/components/root/update_dialog.dart | 56 -- .../settings/color_scheme_picker_dialog.dart | 4 +- .../adaptive/adaptive_pop_sheet_list.dart | 2 +- .../shared/fallbacks/anonymous_fallback.dart | 3 +- .../horizontal_playbutton_card_view.dart | 2 +- .../inter_scrollbar/inter_scrollbar.dart | 4 +- .../shared/links/anchor_button.dart | 2 +- lib/components/shared/links/artist_link.dart | 8 +- .../shared/page_window_title_bar.dart | 51 +- lib/components/shared/playbutton_card.dart | 14 +- .../shared/themed_button_tab_bar.dart | 6 +- .../shared/track_tile/track_options.dart | 224 +++--- .../shared/track_tile/track_tile.dart | 23 +- .../sections/body/track_view_body.dart | 25 +- .../sections/body/track_view_options.dart | 15 - .../sections/header/flexible_header.dart | 5 +- .../sections/header/header_actions.dart | 10 - .../sections/header/header_buttons.dart | 65 +- .../shared/tracks_view/track_view.dart | 5 +- .../shared/tracks_view/track_view_props.dart | 12 +- lib/components/shared/waypoint.dart | 8 +- lib/components/stats/common/album_item.dart | 53 -- lib/components/stats/common/artist_item.dart | 39 - .../stats/common/playlist_item.dart | 46 -- lib/components/stats/common/track_item.dart | 49 -- lib/components/stats/summary/summary.dart | 100 --- .../stats/summary/summary_card.dart | 86 --- lib/components/stats/top/albums.dart | 29 - lib/components/stats/top/artists.dart | 27 - lib/components/stats/top/top.dart | 106 --- lib/components/stats/top/tracks.dart | 31 - lib/extensions/album_simple.dart | 15 + lib/extensions/artist_simple.dart | 12 + lib/extensions/track.dart | 29 + .../configurators/use_close_behavior.dart | 26 +- lib/hooks/configurators/use_deep_linking.dart | 4 +- .../use_disable_battery_optimizations.dart | 6 +- .../configurators/use_get_storage_perms.dart | 13 +- .../configurators/use_init_sys_tray.dart | 128 +++ .../configurators/use_update_checker.dart | 100 +++ .../configurators/use_window_listener.dart | 10 +- lib/hooks/utils/use_palette_color.dart | 7 +- lib/l10n/app_en.arb | 7 +- lib/l10n/app_eu.arb | 329 -------- lib/l10n/app_fi.arb | 329 -------- lib/l10n/app_id.arb | 329 -------- lib/l10n/app_ka.arb | 329 -------- lib/l10n/app_tr.arb | 203 +++-- lib/l10n/l10n.dart | 6 +- lib/main.dart | 75 +- lib/models/connect/connect.dart | 1 + lib/models/connect/connect.freezed.dart | 500 ++---------- lib/models/connect/connect.g.dart | 44 +- lib/models/connect/load.dart | 19 +- lib/models/current_playlist.dart | 1 + lib/models/local_track.dart | 4 +- lib/models/logger.dart | 2 +- lib/models/source_match.g.dart | 2 +- lib/models/spotify/home_feed.freezed.dart | 2 +- lib/models/spotify/home_feed.g.dart | 70 +- .../spotify/recommendation_seeds.freezed.dart | 2 +- .../spotify/recommendation_seeds.g.dart | 3 +- lib/models/spotify_friends.g.dart | 39 +- lib/pages/album/album.dart | 4 +- lib/pages/artist/artist.dart | 2 - lib/pages/artist/section/top_tracks.dart | 3 +- lib/pages/connect/connect.dart | 7 +- lib/pages/connect/control/control.dart | 11 +- lib/pages/desktop_login/desktop_login.dart | 4 +- lib/pages/desktop_login/login_tutorial.dart | 4 +- .../getting_started/getting_started.dart | 2 - .../getting_started/sections/support.dart | 6 +- lib/pages/home/feed/feed_section.dart | 2 - lib/pages/home/genres/genre_playlists.dart | 10 +- lib/pages/home/genres/genres.dart | 10 +- lib/pages/home/home.dart | 37 +- lib/pages/lastfm_login/lastfm_login.dart | 1 - lib/pages/library/library.dart | 4 +- lib/pages/library/local_folder.dart | 240 ------ .../playlist_generate/playlist_generate.dart | 2 - .../playlist_generate_result.dart | 10 +- lib/pages/lyrics/lyrics.dart | 4 +- lib/pages/lyrics/mini_lyrics.dart | 104 ++- lib/pages/mobile_login/mobile_login.dart | 1 - lib/pages/playlist/liked_playlist.dart | 5 +- lib/pages/playlist/playlist.dart | 4 +- lib/pages/profile/profile.dart | 2 - lib/pages/root/root_app.dart | 48 +- lib/pages/search/search.dart | 195 +++-- lib/pages/search/sections/tracks.dart | 4 +- lib/pages/settings/about.dart | 10 - lib/pages/settings/blacklist.dart | 3 +- lib/pages/settings/logs.dart | 2 - lib/pages/settings/sections/about.dart | 7 +- lib/pages/settings/sections/accounts.dart | 30 +- lib/pages/settings/sections/desktop.dart | 4 +- lib/pages/settings/sections/downloads.dart | 4 +- lib/pages/settings/settings.dart | 8 +- lib/pages/stats/albums/albums.dart | 38 - lib/pages/stats/artists/artists.dart | 38 - lib/pages/stats/fees/fees.dart | 65 -- lib/pages/stats/minutes/minutes.dart | 44 -- lib/pages/stats/playlists/playlists.dart | 39 - lib/pages/stats/stats.dart | 35 - lib/pages/stats/streams/streams.dart | 44 -- lib/pages/track/track.dart | 2 - lib/provider/authentication_provider.dart | 43 +- lib/provider/connect/server.dart | 15 +- lib/provider/discord_provider.dart | 6 +- lib/provider/history/history.dart | 129 ---- lib/provider/history/recent.dart | 40 - lib/provider/history/state.dart | 35 - lib/provider/history/state.freezed.dart | 644 ---------------- lib/provider/history/state.g.dart | 55 -- lib/provider/history/summary.dart | 62 -- lib/provider/history/top.dart | 95 --- .../local_tracks/local_tracks_provider.dart | 125 --- .../proxy_playlist/player_listeners.dart | 57 +- .../proxy_playlist/proxy_playlist.dart | 18 +- .../proxy_playlist_provider.dart | 28 +- .../proxy_playlist/skip_segments.dart | 49 +- lib/provider/spotify/lyrics/synced.dart | 48 +- lib/provider/spotify/spotify.dart | 4 +- lib/provider/tray_manager/tray_manager.dart | 79 -- lib/provider/tray_manager/tray_menu.dart | 108 --- .../user_preferences_provider.dart | 16 +- .../user_preferences_state.dart | 5 +- .../user_preferences_state.freezed.dart | 41 +- .../user_preferences_state.g.dart | 12 +- lib/services/audio_player/audio_player.dart | 14 +- lib/services/audio_player/custom_player.dart | 6 +- .../audio_services/audio_services.dart | 10 +- .../audio_services/mobile_audio_service.dart | 4 +- .../spotify_endpoints.dart | 90 ++- lib/services/dio/dio.dart | 3 - lib/services/kv_store/kv_store.dart | 20 - lib/services/song_link/song_link.freezed.dart | 2 +- lib/services/song_link/song_link.g.dart | 3 +- .../sourced_track/models/source_info.g.dart | 2 +- .../sourced_track/models/source_map.g.dart | 15 +- lib/services/sourced_track/sourced_track.dart | 8 +- lib/services/sourced_track/sources/piped.dart | 2 +- .../sourced_track/sources/youtube.dart | 22 +- lib/services/wm_tools/wm_tools.dart | 88 --- lib/themes/theme.dart | 34 +- lib/utils/service_utils.dart | 157 +--- linux/flutter/generated_plugin_registrant.cc | 4 - linux/flutter/generated_plugins.cmake | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 4 +- macos/Podfile.lock | 31 +- pubspec.lock | 728 ++++++++++-------- pubspec.yaml | 120 ++- untranslated_messages.json | 210 +---- windows/CMakeLists.txt | 29 +- .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 1 - windows/packaging/exe/inno_setup.iss | 2 +- windows/runner/Runner.rc | 14 +- 224 files changed, 2933 insertions(+), 8329 deletions(-) delete mode 100644 .dockerignore delete mode 100644 .github/Dockerfile delete mode 100644 .github/Dockerfile.flutter_distributor create mode 100644 bin/gen-credits.dart create mode 100644 bin/translated_messages.dart create mode 100644 bin/untranslated_messages.dart create mode 100644 bin/verify-pkgbuild.dart delete mode 100644 cli/README.md delete mode 100644 cli/cli.dart delete mode 100644 cli/commands/build.dart delete mode 100644 cli/commands/build/android.dart delete mode 100644 cli/commands/build/common.dart delete mode 100644 cli/commands/build/ios.dart delete mode 100644 cli/commands/build/linux.dart delete mode 100644 cli/commands/build/linux_arm.dart delete mode 100644 cli/commands/build/macos.dart delete mode 100644 cli/commands/build/windows.dart delete mode 100644 cli/commands/credits.dart delete mode 100644 cli/commands/install-dependencies.dart delete mode 100644 cli/commands/translated.dart delete mode 100644 cli/commands/untranslated.dart delete mode 100644 cli/core/env.dart delete mode 100644 devtools_options.yaml delete mode 100644 lib/collections/formatters.dart delete mode 100644 lib/components/home/sections/recent.dart delete mode 100644 lib/components/library/local_folder/local_folder_item.dart delete mode 100644 lib/components/root/update_dialog.dart delete mode 100644 lib/components/stats/common/album_item.dart delete mode 100644 lib/components/stats/common/artist_item.dart delete mode 100644 lib/components/stats/common/playlist_item.dart delete mode 100644 lib/components/stats/common/track_item.dart delete mode 100644 lib/components/stats/summary/summary.dart delete mode 100644 lib/components/stats/summary/summary_card.dart delete mode 100644 lib/components/stats/top/albums.dart delete mode 100644 lib/components/stats/top/artists.dart delete mode 100644 lib/components/stats/top/top.dart delete mode 100644 lib/components/stats/top/tracks.dart create mode 100644 lib/hooks/configurators/use_init_sys_tray.dart create mode 100644 lib/hooks/configurators/use_update_checker.dart delete mode 100644 lib/l10n/app_eu.arb delete mode 100644 lib/l10n/app_fi.arb delete mode 100644 lib/l10n/app_id.arb delete mode 100644 lib/l10n/app_ka.arb delete mode 100644 lib/pages/library/local_folder.dart delete mode 100644 lib/pages/stats/albums/albums.dart delete mode 100644 lib/pages/stats/artists/artists.dart delete mode 100644 lib/pages/stats/fees/fees.dart delete mode 100644 lib/pages/stats/minutes/minutes.dart delete mode 100644 lib/pages/stats/playlists/playlists.dart delete mode 100644 lib/pages/stats/stats.dart delete mode 100644 lib/pages/stats/streams/streams.dart delete mode 100644 lib/provider/history/history.dart delete mode 100644 lib/provider/history/recent.dart delete mode 100644 lib/provider/history/state.dart delete mode 100644 lib/provider/history/state.freezed.dart delete mode 100644 lib/provider/history/state.g.dart delete mode 100644 lib/provider/history/summary.dart delete mode 100644 lib/provider/history/top.dart delete mode 100644 lib/provider/local_tracks/local_tracks_provider.dart delete mode 100644 lib/provider/tray_manager/tray_manager.dart delete mode 100644 lib/provider/tray_manager/tray_menu.dart delete mode 100644 lib/services/dio/dio.dart delete mode 100644 lib/services/wm_tools/wm_tools.dart diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index ddfd1517..00000000 --- a/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -build -dist -.dart_tool -.idea -.github -.git \ No newline at end of file diff --git a/.env.example b/.env.example index 56665663..22abd24b 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,3 @@ ENABLE_UPDATE_CHECK= LASTFM_API_KEY= LASTFM_API_SECRET= - -# Release channel. Can be: nightly, stable -RELEASE_CHANNEL= diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index df8efa0e..7ca74200 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.22.1", + "flutterSdkVersion": "3.19.1", "flavors": {} } \ No newline at end of file diff --git a/.github/Dockerfile b/.github/Dockerfile deleted file mode 100644 index 2e393449..00000000 --- a/.github/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -ARG FLUTTER_VERSION - -FROM --platform=linux/arm64 krtirtho/flutter_distributor_arm64:${FLUTTER_VERSION} - -ARG BUILD_VERSION - -WORKDIR /app - -COPY . . - -RUN chown -R $(whoami) /app - -RUN flutter pub get - -RUN alias dpkg-deb="dpkg-deb --Zxz" &&\ - flutter_distributor package --platform=linux --targets=deb --skip-clean - -RUN make tar VERSION=${BUILD_VERSION} ARCH=arm64 PKG_ARCH=aarch64 - -RUN mv build/spotube-linux-*-aarch64.tar.xz dist/ &&\ - mv dist/**/spotube-*-linux.deb dist/Spotube-linux-aarch64.deb - -CMD [ "sleep", "5000000" ] \ No newline at end of file diff --git a/.github/Dockerfile.flutter_distributor b/.github/Dockerfile.flutter_distributor deleted file mode 100644 index 952b9158..00000000 --- a/.github/Dockerfile.flutter_distributor +++ /dev/null @@ -1,23 +0,0 @@ -FROM --platform=linux/arm64 ubuntu:22.04 - -ARG FLUTTER_VERSION - -RUN apt-get clean &&\ - apt-get update &&\ - apt-get install -y bash curl file git unzip xz-utils zip libglu1-mesa cmake tar clang ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev rpm && \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /home/flutter - -RUN git clone https://github.com/flutter/flutter.git -b ${FLUTTER_VERSION} --single-branch flutter-sdk - -RUN flutter-sdk/bin/flutter precache - -RUN flutter-sdk/bin/flutter config --no-analytics - -ENV PATH="$PATH:/home/flutter/flutter-sdk/bin" -ENV PATH="$PATH:/home/flutter/flutter-sdk/bin/cache/dart-sdk/bin" -ENV PATH="$PATH:/home/flutter/.pub-cache/bin" -ENV PUB_CACHE="/home/flutter/.pub-cache" - -RUN dart pub global activate flutter_distributor \ No newline at end of file diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index 2844986d..156d1a07 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,7 +4,7 @@ on: pull_request: env: - FLUTTER_VERSION: '3.22.1' + FLUTTER_VERSION: '3.19.5' jobs: lint: diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 0d39ab1d..805a89ac 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.7.0 + default: 3.1.0 required: true dry_run: description: Dry run diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 8e68211c..d9fbd0c7 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -2,109 +2,100 @@ name: Spotube Release Binary on: workflow_dispatch: inputs: + version: + description: Version to release (x.x.x) + default: 3.6.0 + required: true channel: type: choice + description: Release Channel + required: true options: - stable - nightly default: nightly - description: The release channel debug: + description: Debug on failed when channel is nightly + required: true type: boolean default: false - description: Debug with SSH toggle - required: false dry_run: + description: Dry run + required: true type: boolean - default: false - description: Dry run without uploading to release + default: true env: - FLUTTER_VERSION: 3.22.1 - -permissions: - contents: write + FLUTTER_VERSION: '3.19.1' jobs: - build_platform: - strategy: - matrix: - include: - - os: ubuntu-latest - platform: linux - files: | - dist/Spotube-linux-x86_64.deb - dist/Spotube-linux-x86_64.rpm - dist/spotube-linux-*-x86_64.tar.xz - - os: ubuntu-latest - platform: linux_arm - files: | - dist/Spotube-linux-aarch64.deb - dist/spotube-linux-*-aarch64.tar.xz - - os: ubuntu-latest - platform: android - files: | - build/Spotube-android-all-arch.apk - build/Spotube-playstore-all-arch.aab - - os: windows-latest - platform: windows - files: | - dist/Spotube-windows-x86_64.nupkg - dist/Spotube-windows-x86_64-setup.exe - - os: macos-latest - platform: ios - files: | - Spotube-iOS.ipa - - os: macos-14 - platform: macos - files: | - build/Spotube-macos-universal.dmg - build/Spotube-macos-universal.pkg - runs-on: ${{matrix.os}} + windows: + runs-on: windows-latest steps: - uses: actions/checkout@v4 - uses: subosito/flutter-action@v2.12.0 with: cache: true - cache-key: ${{ runner.os }}-flutter-${{ hashFiles('**/pubspec.yaml') }} flutter-version: ${{ env.FLUTTER_VERSION }} - - name: Setup Java - if: ${{matrix.platform == 'android'}} - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: '17' - cache: 'gradle' - check-latest: true - - name: Set up QEMU - if: ${{matrix.platform == 'linux_arm'}} - uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - if: ${{matrix.platform == 'linux_arm'}} - uses: docker/setup-buildx-action@v3 - - name: Install ${{matrix.platform}} dependencies + - name: Replace pubspec version and BUILD_VERSION Env (nightly) + if: ${{ inputs.channel == 'nightly' }} run: | + choco install sed make yq -y + yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml + yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml + "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $env:GITHUB_ENV + + - name: BUILD_VERSION Env (stable) + if: ${{ inputs.channel == 'stable' }} + run: | + "BUILD_VERSION=${{ inputs.version }}" >> $env:GITHUB_ENV + + - name: Replace version in files + run: | + choco install sed make -y + sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" windows/runner/Runner.rc + sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" choco-struct/tools/VERIFICATION.txt + sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.BUILD_VERSION }}/" choco-struct/spotube.nuspec + + - name: Create Stable .env + if: ${{ inputs.channel == 'stable' }} + run: echo '${{ secrets.DOTENV_RELEASE }}' > .env + + - name: Create Nightly .env + if: ${{ inputs.channel == 'nightly' }} + run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env + + - name: Generating Secrets + run: | + flutter config --enable-windows-desktop flutter pub get - dart cli/cli.dart install-dependencies --platform=${{matrix.platform}} + dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - name: Sign Apk - if: ${{matrix.platform == 'android'}} + - name: Build Windows Executable run: | - echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks - echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties - - - name: Build ${{matrix.platform}} binaries - run: dart cli/cli.dart build ${{matrix.platform}} - env: - CHANNEL: ${{inputs.channel}} - DOTENV: ${{secrets.DOTENV_RELEASE}} - - - uses: actions/upload-artifact@v3 + dart pub global activate flutter_distributor + make innoinstall + 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 + if: ${{ inputs.channel == 'stable' }} + run: | + Set-Variable -Name HASH -Value (Get-FileHash dist\Spotube-windows-x86_64-setup.exe).Hash + sed -i "s/%{{WIN_SHA256}}%/$HASH/" choco-struct/tools/VERIFICATION.txt + make choco + mv dist/spotube.*.nupkg dist/Spotube-windows-x86_64.nupkg + + + - name: Upload Artifact + uses: actions/upload-artifact@v3 with: if-no-files-found: error name: Spotube-Release-Binaries - path: ${{matrix.files}} + path: | + dist/Spotube-windows-x86_64.nupkg + dist/Spotube-windows-x86_64-setup.exe - name: Debug With SSH When fails if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} @@ -112,10 +103,314 @@ jobs: with: limit-access-to-actor: true + linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2.12.0 + with: + cache: true + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Get current date + id: date + run: echo "::set-output name=date::$(date +'%Y-%m-%d')" + + - name: Install Dependencies + run: | + sudo apt-get update -y + sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev + + - name: Install AppImage Tool + run: | + wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" + chmod +x appimagetool + mv appimagetool /usr/local/bin/ + + - name: Replace pubspec version and BUILD_VERSION Env (nightly) + if: ${{ inputs.channel == 'nightly' }} + run: | + curl -sS https://webi.sh/yq | sh + yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml + yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml + echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV + + - name: BUILD_VERSION Env (stable) + if: ${{ inputs.channel == 'stable' }} + run: | + echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV + + - name: Create Stable .env + if: ${{ inputs.channel == 'stable' }} + run: echo '${{ secrets.DOTENV_RELEASE }}' > .env + + - name: Create Nightly .env + if: ${{ inputs.channel == 'nightly' }} + run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env + + - name: Replace Version in files + run: | + sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml + + - name: Generate Secrets + run: | + flutter config --enable-linux-desktop + flutter pub get + dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + + - name: Build Linux Packages + run: | + 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=rpm + + - name: Create tar.xz (stable) + if: ${{ inputs.channel == 'stable' }} + run: make tar VERSION=${{ env.BUILD_VERSION }} ARCH=x64 PKG_ARCH=x86_64 + + - name: Create tar.xz (nightly) + if: ${{ inputs.channel == 'nightly' }} + run: make tar VERSION=nightly ARCH=x64 PKG_ARCH=x86_64 + + - name: Move Files to dist + run: | + 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 + + + - uses: actions/upload-artifact@v3 + if: ${{ inputs.channel == 'stable' }} + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + dist/Spotube-linux-x86_64.deb + dist/Spotube-linux-x86_64.rpm + dist/spotube-linux-${{ env.BUILD_VERSION }}-x86_64.tar.xz + + - uses: actions/upload-artifact@v3 + if: ${{ inputs.channel == 'nightly' }} + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + dist/Spotube-linux-x86_64.deb + dist/Spotube-linux-x86_64.rpm + dist/spotube-linux-nightly-x86_64.tar.xz + + - name: Debug With SSH When fails + if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true + + + android: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2.12.0 + with: + cache: true + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Install Dependencies + run: | + sudo apt-get update -y + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse xmlstarlet + + - name: Replace pubspec version and BUILD_VERSION Env (nightly) + if: ${{ inputs.channel == 'nightly' }} + run: | + curl -sS https://webi.sh/yq | sh + yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml + yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml + echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV + + - name: BUILD_VERSION Env (stable) + if: ${{ inputs.channel == 'stable' }} + run: | + echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV + + - name: Create Stable .env + if: ${{ inputs.channel == 'stable' }} + run: echo '${{ secrets.DOTENV_RELEASE }}' > .env + + - name: Create Nightly .env + if: ${{ inputs.channel == 'nightly' }} + run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env + + - name: Generate Secrets + run: | + flutter pub get + dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + + - name: Sign Apk + run: | + echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks + echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties + + - name: Build Apk + run: | + flutter build apk --flavor ${{ inputs.channel }} + mv build/app/outputs/flutter-apk/app-${{ inputs.channel }}-release.apk build/Spotube-android-all-arch.apk + + - name: Build Playstore AppBundle + run: | + echo 'ENABLE_UPDATE_CHECK=0' >> .env + dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + export MANIFEST=android/app/src/main/AndroidManifest.xml + xmlstarlet ed -d '//meta-data[@android:name="com.google.android.gms.car.application"]' $MANIFEST > $MANIFEST.tmp + mv $MANIFEST.tmp $MANIFEST + flutter build appbundle --flavor ${{ inputs.channel }} + mv build/app/outputs/bundle/${{ inputs.channel }}Release/app-${{ inputs.channel }}-release.aab build/Spotube-playstore-all-arch.aab + + + - uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + build/Spotube-android-all-arch.apk + build/Spotube-playstore-all-arch.aab + + - name: Debug With SSH When fails + if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true + + macos: + + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2.12.0 + with: + cache: true + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Replace pubspec version and BUILD_VERSION Env (nightly) + if: ${{ inputs.channel == 'nightly' }} + run: | + brew install yq + yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml + yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml + echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV + + - name: BUILD_VERSION Env (stable) + if: ${{ inputs.channel == 'stable' }} + run: | + echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV + + - name: Create Stable .env + if: ${{ inputs.channel == 'stable' }} + run: echo '${{ secrets.DOTENV_RELEASE }}' > .env + + - name: Create Nightly .env + if: ${{ inputs.channel == 'nightly' }} + run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env + + - name: Generate Secrets + run: | + dart pub global activate flutter_distributor + flutter pub get + dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + + - name: Build Macos App + run: | + flutter config --enable-macos-desktop + flutter build macos + du -sh build/macos/Build/Products/Release/spotube.app + + - name: Package Macos App + run: | + brew install python-setuptools + npm install -g appdmg + mkdir -p build/${{ env.BUILD_VERSION }} + appdmg appdmg.json build/Spotube-macos-universal.dmg + flutter_distributor package --platform=macos --targets pkg --skip-clean + mv dist/**/spotube-*-macos.pkg build/Spotube-macos-universal.pkg + + - uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + build/Spotube-macos-universal.dmg + build/Spotube-macos-universal.pkg + + - name: Debug With SSH When fails + if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true + + iOS: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2.10.0 + with: + cache: true + flutter-version: ${{ env.FLUTTER_VERSION }} + + - name: Replace pubspec version and BUILD_VERSION Env (nightly) + if: ${{ inputs.channel == 'nightly' }} + run: | + brew install yq + yq -i '.version |= sub("\+\d+", "+${{ inputs.channel }}.")' pubspec.yaml + yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml + echo "BUILD_VERSION=${{ inputs.version }}+${{ inputs.channel }}.${{ github.run_number }}" >> $GITHUB_ENV + + - name: BUILD_VERSION Env (stable) + if: ${{ inputs.channel == 'stable' }} + run: | + echo "BUILD_VERSION=${{ inputs.version }}" >> $GITHUB_ENV + + - name: Create Stable .env + if: ${{ inputs.channel == 'stable' }} + run: echo '${{ secrets.DOTENV_RELEASE }}' > .env + + - name: Create Nightly .env + if: ${{ inputs.channel == 'nightly' }} + run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env + + - name: Generate Secrets + run: | + flutter pub get + dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns + + - name: Build iOS iPA + run: | + flutter build ios --release --no-codesign --flavor ${{ inputs.channel }} + ln -sf ./build/ios/iphoneos Payload + zip -r9 Spotube-iOS.ipa Payload/${{ inputs.channel }}.app + + - uses: actions/upload-artifact@v3 + with: + if-no-files-found: error + name: Spotube-Release-Binaries + path: | + Spotube-iOS.ipa + + - name: Debug With SSH When fails + if: ${{ failure() && inputs.debug && inputs.channel == 'nightly' }} + uses: mxschmitt/action-tmate@v3 + with: + limit-access-to-actor: true + upload: runs-on: ubuntu-latest + needs: - - build_platform + - windows + - linux + - android + - macos + - iOS steps: - uses: actions/download-artifact@v3 with: @@ -131,10 +426,6 @@ jobs: md5sum Spotube-Release-Binaries/* >> RELEASE.md5sum sha256sum Spotube-Release-Binaries/* >> RELEASE.sha256sum sed -i 's|Spotube-Release-Binaries/||' RELEASE.sha256sum RELEASE.md5sum - - - name: Extract pubspec version - run: | - echo "PUBSPEC_VERSION=$(grep -oP 'version:\s*\K[^+]+(?=\+)' pubspec.yaml)" >> $GITHUB_ENV - uses: actions/upload-artifact@v3 with: @@ -149,7 +440,7 @@ jobs: uses: ncipollo/release-action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} - tag: v${{ env.PUBSPEC_VERSION }} # mind the "v" prefix + tag: v${{ inputs.version }} # mind the "v" prefix omitBodyDuringUpdate: true omitNameDuringUpdate: true omitPrereleaseDuringUpdate: true @@ -167,8 +458,3 @@ jobs: omitPrereleaseDuringUpdate: true allowUpdates: true artifacts: Spotube-Release-Binaries/*,RELEASE.sha256sum,RELEASE.md5sum - body: | - Build Number: ${{github.run_number}} - - Nightly release includes newest features but may contain bugs - It is preferred to use the stable version unless you know what you're doing diff --git a/.metadata b/.metadata index 828f2c0a..082985ad 100644 --- a/.metadata +++ b/.metadata @@ -1,11 +1,11 @@ # This file tracks properties of this Flutter project. # Used by Flutter tool to assess capabilities and perform upgrades etc. # -# This file should be version controlled and should not be manually edited. +# This file should be version controlled. version: - revision: "300451adae589accbece3490f4396f10bdf15e6e" - channel: "stable" + revision: eb6d86ee27deecba4a83536aa20f366a6044895c + channel: stable project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e - - platform: windows - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e + create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + - platform: macos + create_revision: eb6d86ee27deecba4a83536aa20f366a6044895c + base_revision: eb6d86ee27deecba4a83536aa20f366a6044895c # User provided section diff --git a/.vscode/settings.json b/.vscode/settings.json index de5fbd69..29c5ba4e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,5 @@ "explorer.fileNesting.patterns": { "pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,flutter_launcher_icons*.yaml,flutter_native_splash*.yaml", "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", - "*.dart": "${capture}.g.dart,${capture}.freezed.dart", } } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 21fb79d4..21ca4b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,39 +2,7 @@ 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.7.0](https://github.com/krtirtho/spotube/compare/v3.6.0...v3.7.0) (2024-06-03) - - -### Features - -* local library folder cards ([fc5bfa0](https://github.com/krtirtho/spotube/commit/fc5bfa089ce2f46ab786565d6750564d704ee7e0)) -* Local music library ([#1479](https://github.com/krtirtho/spotube/issues/1479)) ([22caa81](https://github.com/krtirtho/spotube/commit/22caa818f4ac31626aaff6952e43512b42237d00)) -* personalized stats based on local music history ([#1522](https://github.com/krtirtho/spotube/issues/1522)) ([82307bc](https://github.com/krtirtho/spotube/commit/82307bc030035b03ab1b8d8ec7b24da19a866b12)) -* play initially available tracks of playlist/album immediately and fetch rest in background [#670](https://github.com/krtirtho/spotube/issues/670) ([02acbd9](https://github.com/krtirtho/spotube/commit/02acbd93271145dde365f6c547e0d9d902be65f1)) -* **player:** add volume slider floating label showing percentage ([#1445](https://github.com/krtirtho/spotube/issues/1445)) ([8fad225](https://github.com/krtirtho/spotube/commit/8fad2251b3536e9468e0fb193939ead98bad3bc6)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -* **translations:** add Basque translation ([#1493](https://github.com/krtirtho/spotube/issues/1493)) ([dbc1c45](https://github.com/krtirtho/spotube/commit/dbc1c452dd53153c61589f956ea9836cea7bf2bb)) -* **translations:** add Finnish translations ([#1449](https://github.com/krtirtho/spotube/issues/1449)) ([edc997e](https://github.com/krtirtho/spotube/commit/edc997e7470ce17f60c96b8198dc8851cbf21f18)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -* **translations:** add georgian language ([#1450](https://github.com/krtirtho/spotube/issues/1450)) ([1e7f0e1](https://github.com/krtirtho/spotube/commit/1e7f0e1fe71e0a8d86614fc884861f8791469112)) -* **translations:** add Indonesian translation ([#1426](https://github.com/krtirtho/spotube/issues/1426)) ([0280654](https://github.com/krtirtho/spotube/commit/0280654bb6bad373aee521f5a866228d2d38f038)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -* **translations:** Improve tr locales ([#1419](https://github.com/krtirtho/spotube/issues/1419)) ([bf45681](https://github.com/krtirtho/spotube/commit/bf45681deb951c772bf6ca05e213c949c04bded1)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -* upgrade to Flutter 3.22.0 ([71341ec](https://github.com/krtirtho/spotube/commit/71341ec0bda6ed985b43836712075b97a2cf8bac)) - - -### Bug Fixes - -* fallback to LRCLIB when lyrics line less than 6 lines [#1461](https://github.com/krtirtho/spotube/issues/1461) ([9aea354](https://github.com/krtirtho/spotube/commit/9aea35468fa7cd176ddc8810b37b90c2d8246931)) -* **linux:** tray icon not showing [#541](https://github.com/krtirtho/spotube/issues/541) ([7ac7917](https://github.com/krtirtho/spotube/commit/7ac791757abb30f40374c169c4211916287bb3f3)) -* local track not showing up in queue ([d82261c](https://github.com/krtirtho/spotube/commit/d82261cb25ece63f85af0e40216cf32dccdc9dd5)) -* **macos:** Logs directory not created by default [#1353](https://github.com/krtirtho/spotube/issues/1353) ([4ca8939](https://github.com/krtirtho/spotube/commit/4ca893950b07f678acf7db690112c47d21e54782)) -* **playback:** skipping tracks with unplayable sources instead of falling back [#1492](https://github.com/krtirtho/spotube/issues/1492) ([c607a33](https://github.com/krtirtho/spotube/commit/c607a330ed279dfbebe8d4bd325745ac6301a58f)) -* **search:** load more button not working [#1417](https://github.com/krtirtho/spotube/issues/1417) ([7e07c2e](https://github.com/krtirtho/spotube/commit/7e07c2e1985da7ccb96b1fac2ecd703720068d26)) -* some text are garbled in different parts of the app [#1463](https://github.com/krtirtho/spotube/issues/1463) [#1505](https://github.com/krtirtho/spotube/issues/1505) ([d2683c5](https://github.com/krtirtho/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) -* spotify friends and user profile icon (mobile) showing when not authenticated [#1410](https://github.com/krtirtho/spotube/issues/1410) ([9bccbc9](https://github.com/krtirtho/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) -* **updater:** dead link ([#1408](https://github.com/krtirtho/spotube/issues/1408)) ([6907f9c](https://github.com/krtirtho/spotube/commit/6907f9c756d8f49aadb1b23a2a1dc8bf7d658dc0)), closes [#1310](https://github.com/krtirtho/spotube/issues/1310) [#1311](https://github.com/krtirtho/spotube/issues/1311) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1171](https://github.com/krtirtho/spotube/issues/1171) [#1082](https://github.com/krtirtho/spotube/issues/1082) [#1082](https://github.com/krtirtho/spotube/issues/1082) -* windows SSL Certificate error breaking login [#905](https://github.com/krtirtho/spotube/issues/905) ([#1474](https://github.com/krtirtho/spotube/issues/1474)) ([937a706](https://github.com/krtirtho/spotube/commit/937a706ac9c0e59943b2609e5cc398dcdbed2344)), closes [#1468](https://github.com/krtirtho/spotube/issues/1468) -* **windows:** installer tries to install in current directory ([c3c9fc5](https://github.com/krtirtho/spotube/commit/c3c9fc544c68b3d897dd7241a61cab7a199b4539)) - -## [3.6.0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0) (2024-04-15) +## [3.6.0-0](https://github.com/krtirtho/spotube/compare/v3.5.0...v3.6.0-0) (2024-04-15) ### Features diff --git a/android/app/build.gradle b/android/app/build.gradle index 7bcd9b6a..2f85cdeb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,9 +1,3 @@ -plugins { - id "com.android.application" - id "kotlin-android" - id "dev.flutter.flutter-gradle-plugin" -} - def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -12,6 +6,11 @@ if (localPropertiesFile.exists()) { } } +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { flutterVersionCode = '1' @@ -22,6 +21,10 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') if (keystorePropertiesFile.exists()) { @@ -68,9 +71,6 @@ android { release { signingConfig signingConfigs.release } - debug { - signingConfig signingConfigs.release - } } flavorDimensions "default" @@ -81,19 +81,16 @@ android { resValue "string", "app_name_en", "Spotube Nightly" applicationIdSuffix ".nightly" versionNameSuffix "-nightly" - signingConfig signingConfigs.release } dev { dimension "default" resValue "string", "app_name_en", "Spotube Dev" applicationIdSuffix ".dev" versionNameSuffix "-dev" - signingConfig signingConfigs.release } stable { dimension "default" resValue "string", "app_name_en", "Spotube" - signingConfig signingConfigs.release } } @@ -104,6 +101,15 @@ flutter { } dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + constraints { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version") { + because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib") + } + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version") { + because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib") + } + } implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' // other deps so just ignore diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 589e22ff..5ab7a0b5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,11 +24,6 @@ android:usesCleartextTraffic="true" android:requestLegacyExternalStorage="true" > - - - properties.load(reader) } -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.2.1" apply false - id "org.jetbrains.kotlin.android" version "1.8.22" apply false -} - -include ":app" \ No newline at end of file +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/bin/gen-credits.dart b/bin/gen-credits.dart new file mode 100644 index 00000000..f8975335 --- /dev/null +++ b/bin/gen-credits.dart @@ -0,0 +1,103 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:http/http.dart'; +import 'package:html/parser.dart'; +import 'package:pub_api_client/pub_api_client.dart'; +import 'package:pubspec_parse/pubspec_parse.dart'; + +void main() async { + final client = PubClient(); + + final pubspec = Pubspec.parse(File('pubspec.yaml').readAsStringSync()); + + final allDeps = [ + ...pubspec.dependencies.entries, + ...pubspec.devDependencies.entries, + ]; + + final dependencies = allDeps + .where((d) => d.value is HostedDependency) + .map((d) => d.key) + .toSet(); + final packageInfo = await Future.wait(dependencies.map(client.packageInfo)); + + final gitDepsList = List.castFrom, + MapEntry>( + allDeps + .where((d) => d.value is GitDependency) + .map((d) => MapEntry(d.key, d.value as GitDependency)) + .toList(), + ); + + final gitDeps = gitDepsList.map( + (d) { + final uri = Uri.parse( + d.value.url.toString().replaceAll('.git', ''), + ); + return MapEntry( + d.key, + uri.replace( + pathSegments: [ + ...uri.pathSegments, + 'raw', + d.value.ref ?? 'main', + d.value.path ?? '', + 'pubspec.yaml', + ], + ).toString(), + ); + }, + ).toList(); + + final gitPubspecs = await Future.wait( + gitDeps.map( + (d) { + Pubspec parser(res) { + try { + return Pubspec.parse(res.body); + } catch (e) { + final document = parse(res.body); + final pre = document.querySelector('pre'); + if (pre == null) { + log(d.toString()); + rethrow; + } + return Pubspec.parse(pre.text); + } + } + + return get(Uri.parse(d.value)).then(parser).catchError( + (_) => get(Uri.parse(d.value.replaceFirst('/main', '/master'))) + .then(parser), + ); + }, + ), + ); + + // ignore: avoid_print + print( + packageInfo + .map( + (package) => + '1. [${package.name}](${package.latestPubspec.homepage ?? package.url}) - ${package.description.replaceAll('\n', '')}', + ) + .join('\n'), + ); + // ignore: avoid_print + print( + gitPubspecs.map( + (package) { + final packageUrl = package.homepage ?? + gitDepsList + .firstWhereOrNull((dep) => dep.key == package.name) + ?.value + .url + .toString(); + return '1. [${package.name}]($packageUrl) - ${package.description?.replaceAll('\n', '')}'; + }, + ).join('\n'), + ); + exit(0); +} diff --git a/bin/translated_messages.dart b/bin/translated_messages.dart new file mode 100644 index 00000000..1ac8f148 --- /dev/null +++ b/bin/translated_messages.dart @@ -0,0 +1,28 @@ +// ignore_for_file: avoid_print + +import 'dart:convert'; +import 'dart:io'; + +void main(List args) async { + final translatedFile = + jsonDecode(await File('tm.json').readAsString()) as Map; + + for (final MapEntry(:key, :value) in translatedFile.entries) { + print('Updating locale: $key'); + final file = File('lib/l10n/app_$key.arb'); + + final fileContent = + jsonDecode(await file.readAsString()) as Map; + + final newContent = { + ...fileContent, + ...value, + }; + + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(newContent), + ); + + print('✅ Updated locale: $key'); + } +} diff --git a/bin/untranslated_messages.dart b/bin/untranslated_messages.dart new file mode 100644 index 00000000..0b3485a7 --- /dev/null +++ b/bin/untranslated_messages.dart @@ -0,0 +1,50 @@ +// ignore_for_file: avoid_print + +import 'dart:convert'; +import 'dart:io'; + +/// Generate JSON output for untranslated messages with English values +/// for quick translation in ChatGPT +/// +/// Usage: dart bin/untranslated_messages.dart [locale?] +/// +/// Example: dart bin/untranslated_messages.dart +/// +/// or with specific locale (e.g. bn (Bengali)) +/// +/// Example: dart bin/untranslated_messages.dart bn + +void main(List args) { + final file = jsonDecode( + File('untranslated_messages.json').readAsStringSync(), + ) as Map; + + final englishMessages = + jsonDecode(File('lib/l10n/app_en.arb').readAsStringSync()) + as Map; + + final messagesWithValues = {}; + + for (final MapEntry(key: locale, value: messages) in file.entries) { + messagesWithValues[locale] = Map.fromEntries( + messages + .map( + (message) => + MapEntry(message, englishMessages[message]), + ) + .toList() + .cast>(), + ); + } + + 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.", + ); + print( + const JsonEncoder.withIndent(' ').convert( + args.isNotEmpty ? messagesWithValues[args.first] : messagesWithValues, + ), + ); +} diff --git a/bin/verify-pkgbuild.dart b/bin/verify-pkgbuild.dart new file mode 100644 index 00000000..587e63d0 --- /dev/null +++ b/bin/verify-pkgbuild.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; +import 'dart:io'; + +void main() { + Process.run("sh", ["-c", '"./scripts/pkgbuild2json.sh aur-struct/PKGBUILD"']) + .then((result) { + try { + final pkgbuild = jsonDecode(result.stdout); + if (pkgbuild["version"] != + Platform.environment["RELEASE_VERSION"]?.substring(1)) { + throw Exception( + "PKGBUILD version doesn't match current RELEASE_VERSION"); + } + if (pkgbuild["release"] != "1") { + throw Exception("In new releases pkgrel should be 1"); + } + } catch (e) { + // ignore: avoid_print + print("[Failed to parse PKGBUILD] $e"); + } + }); +} diff --git a/build.yaml b/build.yaml index d83d6a20..f074d6e1 100644 --- a/build.yaml +++ b/build.yaml @@ -2,9 +2,4 @@ targets: $default: sources: exclude: - - bin/*.dart - builders: - json_serializable: - options: - any_map: true - explicit_to_json: true + - bin/*.dart \ No newline at end of file diff --git a/cli/README.md b/cli/README.md deleted file mode 100644 index b2ba8ebd..00000000 --- a/cli/README.md +++ /dev/null @@ -1,4 +0,0 @@ -## Spotube Configuration CLI - -This is used for building the project for multiple platforms and having utilities specific for the project. -Written in Dart diff --git a/cli/cli.dart b/cli/cli.dart deleted file mode 100644 index 26190d4c..00000000 --- a/cli/cli.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:args/command_runner.dart'; - -import 'commands/build.dart'; -import 'commands/credits.dart'; -import 'commands/install-dependencies.dart'; -import 'commands/translated.dart'; -import 'commands/untranslated.dart'; - -void main(List args) { - final commandRunner = CommandRunner( - "cli", - "Configuration CLI for Spotube", - ); - - commandRunner.addCommand(InstallDependenciesCommand()); - commandRunner.addCommand(BuildCommand()); - commandRunner.addCommand(CreditsCommand()); - commandRunner.addCommand(TranslatedCommand()); - commandRunner.addCommand(UntranslatedCommand()); - - commandRunner.run(args); -} diff --git a/cli/commands/build.dart b/cli/commands/build.dart deleted file mode 100644 index fdf35a95..00000000 --- a/cli/commands/build.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:args/command_runner.dart'; - -import 'build/android.dart'; -import 'build/ios.dart'; -import 'build/linux.dart'; -import 'build/linux_arm.dart'; -import 'build/macos.dart'; -import 'build/windows.dart'; - -class BuildCommand extends Command { - @override - String get description => "Build for different platforms"; - - @override - String get name => "build"; - - BuildCommand() { - addSubcommand(AndroidBuildCommand()); - addSubcommand(IosBuildCommand()); - addSubcommand(LinuxBuildCommand()); - addSubcommand(LinuxArmBuildCommand()); - addSubcommand(MacosBuildCommand()); - addSubcommand(WindowsBuildCommand()); - } -} diff --git a/cli/commands/build/android.dart b/cli/commands/build/android.dart deleted file mode 100644 index 800522b8..00000000 --- a/cli/commands/build/android.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:collection/collection.dart'; -import 'package:path/path.dart'; -import 'package:xml/xml.dart'; - -import '../../core/env.dart'; -import 'common.dart'; - -class AndroidBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "Build for android"; - - @override - String get name => "android"; - - @override - FutureOr? run() async { - await bootstrap(); - - await shell.run( - "flutter build apk --flavor ${CliEnv.channel.name}", - ); - - await dotEnvFile.writeAsString( - "\nENABLE_UPDATE_CHECK=0", - mode: FileMode.append, - ); - - final androidManifestFile = File( - join(cwd.path, "android", "app", "src", "main", "AndroidManifest.xml")); - - final androidManifestXml = - XmlDocument.parse(await androidManifestFile.readAsString()); - - final deletingElement = - androidManifestXml.findAllElements("meta-data").firstWhereOrNull( - (el) => - el.getAttribute("android:name") == - "com.google.android.gms.car.application", - ); - - deletingElement?.parent?.children.remove(deletingElement); - - await androidManifestFile.writeAsString( - androidManifestXml.toXmlString(pretty: true), - ); - - await shell.run( - """ - dart run build_runner build --delete-conflicting-outputs - flutter build appbundle --flavor ${CliEnv.channel.name} - """, - ); - - final ogApkFile = File( - join( - "build", - "app", - "outputs", - "flutter-apk", - "app-${CliEnv.channel.name}-release.apk", - ), - ); - - await ogApkFile.copy( - join(cwd.path, "build", "Spotube-android-all-arch.apk"), - ); - - final ogAppbundleFile = File( - join( - cwd.path, - "build", - "app", - "outputs", - "bundle", - "${CliEnv.channel.name}Release", - "app-${CliEnv.channel.name}-release.aab", - ), - ); - - await ogAppbundleFile.copy( - join(cwd.path, "build", "Spotube-playstore-all-arch.aab"), - ); - - stdout.writeln("✅ Built Android Apk and Appbundle"); - } -} diff --git a/cli/commands/build/common.dart b/cli/commands/build/common.dart deleted file mode 100644 index 4c7e3e51..00000000 --- a/cli/commands/build/common.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; -import 'package:process_run/shell_run.dart'; -import 'package:pubspec_parse/pubspec_parse.dart'; - -import '../../core/env.dart'; - -mixin BuildCommandCommonSteps on Command { - final shell = Shell(); - Directory get cwd => Directory.current; - - Pubspec? _pubspec; - - Pubspec get pubspec { - if (_pubspec != null) { - return _pubspec!; - } - - final pubspecFile = File(join(cwd.path, "pubspec.yaml")); - _pubspec = Pubspec.parse(pubspecFile.readAsStringSync()); - - return _pubspec!; - } - - String get versionWithoutBuildNumber { - return "${pubspec.version!.major}.${pubspec.version!.minor}.${pubspec.version!.patch}"; - } - - RegExp get versionVarRegExp => - RegExp(r"\%\{\{SPOTUBE_VERSION\}\}\%", multiLine: true); - - File get dotEnvFile => File(join(cwd.path, ".env")); - - Future bootstrap() async { - await dotEnvFile.create(recursive: true); - - await dotEnvFile.writeAsString( - "${CliEnv.dotenv}\n" - "RELEASE_CHANNEL=${CliEnv.channel.name}\n", - ); - - if (CliEnv.channel == BuildChannel.nightly) { - final pubspecFile = File(join(cwd.path, "pubspec.yaml")); - - pubspecFile.writeAsStringSync( - pubspecFile.readAsStringSync().replaceAll( - "version: ${pubspec.version!.canonicalizedVersion}", - "version: $versionWithoutBuildNumber+${CliEnv.ghRunNumber}", - ), - ); - - _pubspec = null; - pubspec; - } - - await shell.run( - """ - flutter pub get - dart run build_runner build --delete-conflicting-outputs - dart pub global activate flutter_distributor - """, - ); - } -} diff --git a/cli/commands/build/ios.dart b/cli/commands/build/ios.dart deleted file mode 100644 index 6460f9ed..00000000 --- a/cli/commands/build/ios.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'dart:async'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; - -import '../../core/env.dart'; -import 'common.dart'; - -class IosBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "iOS build command"; - - @override - String get name => "ios"; - - @override - FutureOr? run() async { - await bootstrap(); - - final buildDirPath = join(cwd.path, "build", "ios", "iphoneos"); - await shell.run( - """ - flutter build ios --release --no-codesign --flavor ${CliEnv.channel.name} - ln -sf $buildDirPath Payload - zip -r9 Spotube-iOS.ipa ${join("Payload", "${CliEnv.channel.name}.app")} - """, - ); - } -} diff --git a/cli/commands/build/linux.dart b/cli/commands/build/linux.dart deleted file mode 100644 index a218720c..00000000 --- a/cli/commands/build/linux.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:io/io.dart'; -import 'package:args/command_runner.dart'; -import 'package:intl/intl.dart'; -import 'package:path/path.dart'; - -import '../../core/env.dart'; -import 'common.dart'; - -class LinuxBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "Linux build command"; - - @override - String get name => "linux"; - - @override - FutureOr? run() async { - stdout.writeln("Replacing versions"); - - final appDataFile = File( - join(cwd.path, "linux", "com.github.KRTirtho.Spotube.appdata.xml"), - ); - - appDataFile.writeAsStringSync( - appDataFile.readAsStringSync().replaceAll( - versionVarRegExp, - '', - ), - ); - - await bootstrap(); - - await shell.run( - """ - flutter_distributor package --platform=linux --targets=deb - flutter_distributor package --platform=linux --targets=rpm - """, - ); - - final tempDir = join(Directory.systemTemp.path, "spotube-tar"); - - final bundleDirPath = - join(cwd.path, "build", "linux", "x64", "release", "bundle"); - - final tarFile = File(join( - cwd.path, - "dist", - "spotube-linux-" - "${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber}" - "-x86_64.tar.xz", - )); - - await copyPath(bundleDirPath, tempDir); - await File(join(cwd.path, "linux", "spotube.desktop")).copy( - join(tempDir, "spotube.desktop"), - ); - await File( - join(cwd.path, "linux", "com.github.KRTirtho.Spotube.appdata.xml"), - ).copy( - join(tempDir, "com.github.KRTirtho.Spotube.appdata.xml"), - ); - await File(join(cwd.path, "assets", "spotube-logo.png")).copy( - join(tempDir, "spotube-logo.png"), - ); - - await shell.run( - "tar -cJf ${tarFile.path} -C $tempDir .", - ); - - final ogDeb = File( - join( - cwd.path, - "dist", - pubspec.version.toString(), - "spotube-${pubspec.version}-linux.deb", - ), - ); - - final ogRpm = File( - join( - cwd.path, - "dist", - pubspec.version.toString(), - "spotube-${pubspec.version}-linux.rpm", - ), - ); - - await ogDeb.copy( - join(cwd.path, "dist", "Spotube-linux-x86_64.deb"), - ); - await ogRpm.copy( - join(cwd.path, "dist", "Spotube-linux-x86_64.rpm"), - ); - - await ogDeb.delete(); - await ogRpm.delete(); - - stdout.writeln("✅ Linux building done"); - } -} diff --git a/cli/commands/build/linux_arm.dart b/cli/commands/build/linux_arm.dart deleted file mode 100644 index a09f0980..00000000 --- a/cli/commands/build/linux_arm.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'dart:async'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; - -import '../../core/env.dart'; -import 'common.dart'; - -class LinuxArmBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "Build Linux Arm"; - - @override - String get name => "linux_arm"; - - @override - FutureOr? run() async { - await bootstrap(); - - await shell.run( - "docker buildx build --platform=linux/arm64 " - "-f ${join(cwd.path, ".github", "Dockerfile")} ${cwd.path} " - "--build-arg FLUTTER_VERSION=${CliEnv.flutterVersion} " - "--build-arg BUILD_VERSION=${CliEnv.channel == BuildChannel.nightly ? "nightly" : versionWithoutBuildNumber} " - "-t krtirtho/spotube_linux_arm:latest " - "--load", - ); - - await shell.run( - """ - docker images ls - docker create --name spotube_linux_arm krtirtho/spotube_linux_arm:latest - docker cp spotube_linux_arm:/app/dist/ dist/ - """, - ); - } -} diff --git a/cli/commands/build/macos.dart b/cli/commands/build/macos.dart deleted file mode 100644 index e8f34b77..00000000 --- a/cli/commands/build/macos.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; - -import 'common.dart'; - -class MacosBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "Macos Build command"; - - @override - String get name => "macos"; - - @override - FutureOr? run() async { - await bootstrap(); - - await shell.run( - """ - flutter build macos - appdmg appdmg.json ${join(cwd.path, "build", "Spotube-macos-universal.dmg")} - flutter_distributor package --platform=macos --targets pkg --skip-clean - """, - ); - - final ogPkg = File( - join( - cwd.path, - "dist", - pubspec.version.toString(), - "spotube-${pubspec.version}-macos.pkg", - ), - ); - - await ogPkg.copy( - join(cwd.path, "build", "Spotube-macos-universal.pkg"), - ); - await ogPkg.delete(); - } -} diff --git a/cli/commands/build/windows.dart b/cli/commands/build/windows.dart deleted file mode 100644 index 15e0bf17..00000000 --- a/cli/commands/build/windows.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; -import 'package:crypto/crypto.dart'; -import 'common.dart'; - -class WindowsBuildCommand extends Command with BuildCommandCommonSteps { - @override - String get description => "Build Windows exe"; - - @override - String get name => "windows"; - - Future innoDependInstall() async { - final innoDependencyPath = join(cwd.path, "build", "inno-depend"); - - await shell.run( - "git clone https://github.com/DomGries/InnoDependencyInstaller.git $innoDependencyPath", - ); - } - - @override - void run() async { - stdout.writeln("Replace versions"); - - final chocoFiles = [ - join(cwd.path, "choco-struct", "tools", "VERIFICATION.txt"), - join(cwd.path, "choco-struct", "spotube.nuspec"), - ]; - - for (final filePath in chocoFiles) { - final file = File(filePath); - final content = file.readAsStringSync(); - final newContent = - content.replaceAll(versionVarRegExp, versionWithoutBuildNumber); - - file.writeAsStringSync(newContent); - } - - await bootstrap(); - await innoDependInstall(); - - await shell.run( - "flutter_distributor package --platform=windows --targets=exe --skip-clean", - ); - - final ogExe = File( - join( - cwd.path, - "dist", - pubspec.version.toString(), - "spotube-${pubspec.version}-windows-setup.exe", - ), - ); - - final exePath = join(cwd.path, "dist", "Spotube-windows-x86_64-setup.exe"); - - await ogExe.copy(exePath); - await ogExe.delete(); - - stdout.writeln("✅ Windows exe built at $exePath"); - - final exeFile = File(exePath); - - final hash = sha256.convert(await exeFile.readAsBytes()).toString(); - - final chocoVerificationFile = File(chocoFiles.first); - - chocoVerificationFile.writeAsStringSync( - chocoVerificationFile.readAsStringSync().replaceAll( - RegExp(r"\%\{\{WIN_SHA256\}\}\%"), - hash, - ), - ); - - await exeFile.copy( - join(cwd.path, "choco-struct", "tools", basename(exeFile.path)), - ); - - await shell.run( - "choco pack ${chocoFiles[1]} --outputdirectory ${join(cwd.path, "dist")}", - ); - - final chocoNupkg = File( - join(cwd.path, "dist", "spotube.$versionWithoutBuildNumber.nupkg"), - ); - - final distNupkgPath = join( - cwd.path, - "dist", - "Spotube-windows-x86_64.nupkg", - ); - - await chocoNupkg.copy(distNupkgPath); - await chocoNupkg.delete(); - - stdout.writeln("✅ Windows nupkg built at $distNupkgPath"); - } -} diff --git a/cli/commands/credits.dart b/cli/commands/credits.dart deleted file mode 100644 index 6bad7a44..00000000 --- a/cli/commands/credits.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'dart:io'; - -import 'package:args/command_runner.dart'; -import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; -import 'package:html/parser.dart'; -import 'package:path/path.dart'; -import 'package:pub_api_client/pub_api_client.dart'; -import 'package:pubspec_parse/pubspec_parse.dart'; - -class CreditsCommand extends Command { - final dio = Dio( - BaseOptions( - responseType: ResponseType.plain, - ), - ); - - @override - String get description => "Generate credits for used Library's authors"; - - @override - String get name => "credits"; - - @override - run() async { - final client = PubClient(); - final cwd = Directory.current; - - final pubspec = Pubspec.parse( - File(join(cwd.path, 'pubspec.yaml')).readAsStringSync(), - ); - - final allDeps = [ - ...pubspec.dependencies.entries, - ...pubspec.devDependencies.entries, - ]; - - final dependencies = allDeps - .where((d) => d.value is HostedDependency) - .map((d) => d.key) - .toSet(); - final packageInfo = await Future.wait(dependencies.map(client.packageInfo)); - - final gitDepsList = List.castFrom, - MapEntry>( - allDeps - .where((d) => d.value is GitDependency) - .map((d) => MapEntry(d.key, d.value as GitDependency)) - .toList(), - ); - - final gitDeps = gitDepsList.map( - (d) { - final uri = Uri.parse( - d.value.url.toString().replaceAll('.git', ''), - ); - return MapEntry( - d.key, - uri.replace( - pathSegments: [ - ...uri.pathSegments, - 'raw', - d.value.ref ?? 'main', - d.value.path ?? '', - 'pubspec.yaml', - ], - ).toString(), - ); - }, - ).toList(); - - final gitPubspecs = await Future.wait( - gitDeps.map( - (d) { - Pubspec parser(Response res) { - try { - return Pubspec.parse(res.data); - } catch (e) { - final document = parse(res.data); - final pre = document.querySelector('pre'); - if (pre == null) { - stdout.writeln(d.toString()); - rethrow; - } - return Pubspec.parse(pre.text); - } - } - - return dio.get(d.value).then(parser).catchError( - (_) => dio - .get(d.value.replaceFirst('/main', '/master')) - .then(parser), - ); - }, - ), - ); - - stdout.writeln( - packageInfo - .map( - (package) => - '1. [${package.name}](${package.latestPubspec.homepage ?? package.url}) - ${package.description.replaceAll('\n', '')}', - ) - .join('\n'), - ); - - stdout.writeln( - gitPubspecs.map( - (package) { - final packageUrl = package.homepage ?? - gitDepsList - .firstWhereOrNull((dep) => dep.key == package.name) - ?.value - .url - .toString(); - return '1. [${package.name}]($packageUrl) - ${package.description?.replaceAll('\n', '')}'; - }, - ).join('\n'), - ); - } -} diff --git a/cli/commands/install-dependencies.dart b/cli/commands/install-dependencies.dart deleted file mode 100644 index 75df28df..00000000 --- a/cli/commands/install-dependencies.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'dart:async'; - -import 'package:args/command_runner.dart'; -import 'package:process_run/shell_run.dart'; - -class InstallDependenciesCommand extends Command { - @override - String get description => "Install platform dependencies"; - - @override - String get name => "install-dependencies"; - - InstallDependenciesCommand() { - argParser.addOption( - "platform", - abbr: "p", - allowed: [ - "windows", - "linux", - "linux_arm", - "macos", - "ios", - "android", - ], - mandatory: true, - ); - } - - @override - FutureOr? run() async { - final shell = Shell(); - - switch (argResults!.option("platform")) { - case "windows": - break; - case "linux": - await shell.run( - """ - sudo apt-get update -y - sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev - """, - ); - break; - case "linux_arm": - await shell.run( - """ - sudo apt-get update -y - sudo apt-get install -y pkg-config make python3-pip python3-setuptools - """, - ); - break; - case "macos": - await shell.run( - """ - brew install python-setuptools - npm install -g appdmg - """, - ); - break; - case "ios": - break; - case "android": - await shell.run( - """ - sudo apt-get update -y - sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse - """, - ); - break; - default: - break; - } - } -} diff --git a/cli/commands/translated.dart b/cli/commands/translated.dart deleted file mode 100644 index 43c4ea49..00000000 --- a/cli/commands/translated.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:async'; - -import 'dart:convert'; -import 'dart:io'; -import 'package:args/command_runner.dart'; -import 'package:path/path.dart'; - -class TranslatedCommand extends Command { - @override - String get description => - "Update translation based on generated translated messages"; - - @override - String get name => "translated"; - - @override - FutureOr? run() async { - final cwd = Directory.current; - final translatedFile = jsonDecode( - await File(join(cwd.path, 'tm.json')).readAsString(), - ) as Map; - - for (final MapEntry(:key, :value) in translatedFile.entries) { - stdout.writeln('Updating locale: $key'); - final file = File(join(cwd.path, 'lib', 'l10n', 'app_$key.arb')); - - final fileContent = - jsonDecode(await file.readAsString()) as Map; - - final newContent = {...fileContent, ...value}; - - await file.writeAsString( - const JsonEncoder.withIndent(' ').convert(newContent), - ); - - stdout.writeln('✅ Updated locale: $key'); - } - } -} diff --git a/cli/commands/untranslated.dart b/cli/commands/untranslated.dart deleted file mode 100644 index dadcd8b5..00000000 --- a/cli/commands/untranslated.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:args/command_runner.dart'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart'; - -class UntranslatedCommand extends Command { - @override - get name => "untranslated"; - @override - get description => - "Generate Untranslated Messages for ChatGPT based Translation"; - - @override - run() async { - final cwd = Directory.current; - final file = jsonDecode( - File(join(cwd.path, 'untranslated_messages.json')).readAsStringSync(), - ) as Map; - - final englishMessages = jsonDecode( - File(join(cwd.path, 'lib', 'l10n', 'app_en.arb')).readAsStringSync(), - ) as Map; - - final messagesWithValues = {}; - - for (final MapEntry(key: locale, value: messages) in file.entries) { - messagesWithValues[locale] = Map.fromEntries( - messages - .map( - (message) => - MapEntry(message, englishMessages[message]), - ) - .toList() - .cast>(), - ); - } - - stdout.writeln( - "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.", - ); - stdout.writeln( - const JsonEncoder.withIndent(' ').convert(messagesWithValues), - ); - } -} diff --git a/cli/core/env.dart b/cli/core/env.dart deleted file mode 100644 index 33cc5df1..00000000 --- a/cli/core/env.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'dart:io'; - -enum BuildChannel { - stable, - nightly; - - factory BuildChannel.fromEnvironment(String name) { - final channel = Platform.environment[name]!; - if (channel == "stable") { - return BuildChannel.stable; - } else if (channel == "nightly") { - return BuildChannel.nightly; - } else { - throw Exception("Invalid channel: $channel"); - } - } -} - -class CliEnv { - static final channel = BuildChannel.fromEnvironment("CHANNEL"); - static final dotenv = Platform.environment["DOTENV"]!; - static final ghRunNumber = Platform.environment["GITHUB_RUN_NUMBER"]; - static final flutterVersion = Platform.environment["FLUTTER_VERSION"]!; -} diff --git a/devtools_options.yaml b/devtools_options.yaml deleted file mode 100644 index 7e7e7f67..00000000 --- a/devtools_options.yaml +++ /dev/null @@ -1 +0,0 @@ -extensions: diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f8533902..1d048cc9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -69,6 +69,9 @@ PODS: - fluttertoast (0.0.2): - Flutter - Toast + - FMDB (2.7.5): + - FMDB/standard (= 2.7.5) + - FMDB/standard (2.7.5) - image_picker_ios (0.0.1): - Flutter - integration_test (0.0.1): @@ -84,7 +87,7 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - permission_handler_apple (9.3.0): + - permission_handler_apple (9.1.1): - Flutter - SDWebImage (5.18.8): - SDWebImage/Core (= 5.18.8) @@ -94,7 +97,7 @@ PODS: - FlutterMacOS - sqflite (0.0.3): - Flutter - - FlutterMacOS + - FMDB (>= 2.7.5) - SwiftyGif (5.4.4) - Toast (4.0.0) - url_launcher_ios (0.0.1): @@ -126,13 +129,14 @@ DEPENDENCIES: - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `.symlinks/plugins/sqflite/darwin`) + - sqflite (from `.symlinks/plugins/sqflite/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: trunk: - DKImagePickerController - DKPhotoGallery + - FMDB - OrderedSet - SDWebImage - SwiftyGif @@ -190,44 +194,45 @@ EXTERNAL SOURCES: shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite: - :path: ".symlinks/plugins/sqflite/darwin" + :path: ".symlinks/plugins/sqflite/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795 + app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875 audio_service: f509d65da41b9521a61f1c404dd58651f265a567 - audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207 + audio_session: 4f3e461722055d21515cf3261b64c973c062f345 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 - file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 - file_selector_ios: 78baf21d03f1e37a7df97bb2494f9cd86de8fa5d + file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de + file_selector_ios: 8c25d700d625e1dcdd6599f2d927072f2254647b Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 - flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 + flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 - fluttertoast: 9f2f8e81bb5ce18facb9748d7855bf5a756fe3db - image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18 - integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 + fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265 + FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5 + integration_test: 13825b8a9334a850581300559b8839134b124670 media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3 media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c - package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c - path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c - permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 + path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a - shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 - sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 - url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 + url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 34793f68..13f624a4 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -324,7 +324,6 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 6E9FEF583EA597C8B76255B2 /* [CP] Embed Pods Frameworks */, - 46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -347,7 +346,6 @@ B536BD992B405DB1009B3CE4 /* Embed Frameworks */, B536BD9A2B405DB1009B3CE4 /* Thin Binary */, A6D446F111DE4C4A202BE7F7 /* [CP] Embed Pods Frameworks */, - 2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -370,7 +368,6 @@ B536BDB62B405FDE009B3CE4 /* Embed Frameworks */, B536BDB72B405FDE009B3CE4 /* Thin Binary */, 244D41CE80E4BC0FFD63F8C6 /* [CP] Embed Pods Frameworks */, - 4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -393,7 +390,6 @@ B536BDD82B4060B3009B3CE4 /* Embed Frameworks */, B536BDD92B4060B3009B3CE4 /* Thin Binary */, D566C841A84D807A607F6DE5 /* [CP] Embed Pods Frameworks */, - 5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -527,23 +523,6 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 2DEF3CF18D30E819C0FF4BCE /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-stable/Pods-stable-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -560,57 +539,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 46F6EB27C31C41D86428A28B /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - 4DD66E9E53D92195290872BE /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-dev/Pods-dev-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - 5C9D945D6569D9C3AC420285 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-nightly/Pods-nightly-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 5CD4405E93760FBD048E36E2 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/lib/collections/env.dart b/lib/collections/env.dart index df45cee9..50fe1e6a 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -1,13 +1,8 @@ import 'package:envied/envied.dart'; -import 'package:spotube/utils/platform.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; part 'env.g.dart'; -enum ReleaseChannel { - nightly, - stable, -} - @Envied(obfuscate: true, requireEnvFile: true, path: ".env") abstract class Env { @EnviedField(varName: 'SPOTIFY_SECRETS') @@ -30,15 +25,8 @@ abstract class Env { @EnviedField(varName: 'ENABLE_UPDATE_CHECK', defaultValue: "1") static final String _enableUpdateChecker = _Env._enableUpdateChecker; - @EnviedField(varName: "RELEASE_CHANNEL", defaultValue: "nightly") - static final String _releaseChannel = _Env._releaseChannel; - - static ReleaseChannel get releaseChannel => _releaseChannel == "stable" - ? ReleaseChannel.stable - : ReleaseChannel.nightly; - static bool get enableUpdateChecker => - kIsFlatpak || _enableUpdateChecker == "1"; + DesktopTools.platform.isFlatpak || _enableUpdateChecker == "1"; static String discordAppId = "1176718791388975124"; -} \ No newline at end of file +} diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 7391d3a0..4df19dfc 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -1,4 +1,5 @@ import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; diff --git a/lib/collections/formatters.dart b/lib/collections/formatters.dart deleted file mode 100644 index 0aed9e9f..00000000 --- a/lib/collections/formatters.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:intl/intl.dart'; - -final compactNumberFormatter = NumberFormat.compact(); -final usdFormatter = NumberFormat.compactCurrency( - locale: 'en-US', - symbol: r"$", - decimalDigits: 2, -); diff --git a/lib/collections/initializers.dart b/lib/collections/initializers.dart index 976661fc..9627de1c 100644 --- a/lib/collections/initializers.dart +++ b/lib/collections/initializers.dart @@ -1,10 +1,9 @@ import 'dart:io'; - -import 'package:spotube/utils/platform.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:win32_registry/win32_registry.dart'; Future registerWindowsScheme(String scheme) async { - if (!kIsWindows) return; + if (!DesktopTools.platform.isWindows) return; String appPath = Platform.resolvedExecutable; String protocolRegKey = 'Software\\Classes\\$scheme'; diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 579aff18..5f60959e 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -7,10 +7,6 @@ import 'package:go_router/go_router.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/components/player/player_controls.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/pages/library/library.dart'; -import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/pages/search/search.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; @@ -71,16 +67,16 @@ class HomeTabAction extends Action { final router = intent.ref.read(routerProvider); switch (intent.tab) { case HomeTabs.browse: - router.goNamed(HomePage.name); + router.go("/"); break; case HomeTabs.search: - router.goNamed(SearchPage.name); + router.go("/search"); break; case HomeTabs.library: - router.goNamed(LibraryPage.name); + router.go("/library"); break; case HomeTabs.lyrics: - router.goNamed(LyricsPage.name); + router.go("/lyrics"); break; } return null; diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index f46e0efe..45456d69 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -81,10 +81,10 @@ abstract class LanguageLocals { // name: "Bashkir", // nativeName: "башҡорт теле", // ), - "eu": const ISOLanguageName( - name: "Basque", - nativeName: "euskara", - ), + // "eu": const ISOLanguageName( + // name: "Basque", + // nativeName: "euskara,", + // ), // "be": const ISOLanguageName( // name: "Belarusian", // nativeName: "Беларуская", @@ -197,10 +197,10 @@ abstract class LanguageLocals { // name: "Fijian", // nativeName: "vosa Vakaviti", // ), - "fi": const ISOLanguageName( - name: "Finnish", - nativeName: "suomi", - ), + // "fi": const ISOLanguageName( + // name: "Finnish", + // nativeName: "suomi", + // ), "fr": const ISOLanguageName( name: "French", nativeName: "français", @@ -213,10 +213,10 @@ abstract class LanguageLocals { // name: "Galician", // nativeName: "Galego", // ), - "ka": const ISOLanguageName( - name: "Georgian", - nativeName: "ქართული", - ), + // "ka": const ISOLanguageName( + // name: "Georgian", + // nativeName: "ქართული", + // ), "de": const ISOLanguageName( name: "German", nativeName: "Deutsch", @@ -265,10 +265,10 @@ abstract class LanguageLocals { // name: "Interlingua", // nativeName: "Interlingua", // ), - "id": const ISOLanguageName( - name: "Indonesian", - nativeName: "Bahasa Indonesia", - ), + // "id": const ISOLanguageName( + // name: "Indonesian", + // nativeName: "Bahasa Indonesia", + // ), // "ie": const ISOLanguageName( // name: "Interlingue", // nativeName: "Occidental", diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index dc2e4b7c..080cbd8a 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -14,7 +14,6 @@ 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/local_folder.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart'; @@ -25,13 +24,6 @@ import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/logs.dart'; -import 'package:spotube/pages/stats/albums/albums.dart'; -import 'package:spotube/pages/stats/artists/artists.dart'; -import 'package:spotube/pages/stats/fees/fees.dart'; -import 'package:spotube/pages/stats/minutes/minutes.dart'; -import 'package:spotube/pages/stats/playlists/playlists.dart'; -import 'package:spotube/pages/stats/stats.dart'; -import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; @@ -58,7 +50,6 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: "/", - name: HomePage.name, redirect: (context, state) async { final authNotifier = ref.read(authenticationProvider.notifier); final json = await authNotifier.box.get(authNotifier.cacheKey); @@ -75,13 +66,11 @@ final routerProvider = Provider((ref) { routes: [ GoRoute( path: "genres", - name: GenrePage.name, pageBuilder: (context, state) => const SpotubePage(child: GenrePage()), ), GoRoute( path: "genre/:categoryId", - name: GenrePlaylistsPage.name, pageBuilder: (context, state) => SpotubePage( child: GenrePlaylistsPage( category: state.extra as Category, @@ -90,7 +79,6 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "feeds/:feedId", - name: HomeFeedSectionPage.name, pageBuilder: (context, state) => SpotubePage( child: HomeFeedSectionPage( sectionUri: state.pathParameters["feedId"] as String, @@ -101,62 +89,45 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/search", - name: SearchPage.name, + name: "Search", pageBuilder: (context, state) => const SpotubePage(child: SearchPage()), ), GoRoute( path: "/library", - name: LibraryPage.name, + name: "Library", pageBuilder: (context, state) => const SpotubePage(child: LibraryPage()), routes: [ GoRoute( - path: "generate", - name: PlaylistGeneratorPage.name, - pageBuilder: (context, state) => - const SpotubePage(child: PlaylistGeneratorPage()), - routes: [ - GoRoute( - path: "result", - name: PlaylistGenerateResultPage.name, - pageBuilder: (context, state) => SpotubePage( - child: PlaylistGenerateResultPage( - state: state.extra as GeneratePlaylistProviderInput, + path: "generate", + pageBuilder: (context, state) => + const SpotubePage(child: PlaylistGeneratorPage()), + routes: [ + GoRoute( + path: "result", + pageBuilder: (context, state) => SpotubePage( + child: PlaylistGenerateResultPage( + state: state.extra as GeneratePlaylistProviderInput, + ), ), ), - ) - ], - ), - GoRoute( - path: "local", - name: LocalLibraryPage.name, - pageBuilder: (context, state) { - assert(state.extra is String); - return SpotubePage( - child: LocalLibraryPage(state.extra as String, - isDownloads: - state.uri.queryParameters["downloads"] != null), - ); - }, - ), + ]), ]), GoRoute( path: "/lyrics", - name: LyricsPage.name, + name: "Lyrics", pageBuilder: (context, state) => const SpotubePage(child: LyricsPage()), ), GoRoute( path: "/settings", - name: SettingsPage.name, pageBuilder: (context, state) => const SpotubePage( child: SettingsPage(), ), routes: [ GoRoute( path: "blacklist", - name: BlackListPage.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const BlackListPage(), ), @@ -164,14 +135,12 @@ final routerProvider = Provider((ref) { if (!kIsWeb) GoRoute( path: "logs", - name: LogsPage.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const LogsPage(), ), ), GoRoute( path: "about", - name: AboutSpotube.name, pageBuilder: (context, state) => SpotubeSlidePage( child: const AboutSpotube(), ), @@ -180,7 +149,6 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/album/:id", - name: AlbumPage.name, pageBuilder: (context, state) { assert(state.extra is AlbumSimple); return SpotubePage( @@ -190,7 +158,6 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/artist/:id", - name: ArtistPage.name, pageBuilder: (context, state) { assert(state.pathParameters["id"] != null); return SpotubePage( @@ -199,7 +166,6 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/playlist/:id", - name: PlaylistPage.name, pageBuilder: (context, state) { assert(state.extra is PlaylistSimple); return SpotubePage( @@ -211,7 +177,6 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/track/:id", - name: TrackPage.name, pageBuilder: (context, state) { final id = state.pathParameters["id"]!; return SpotubePage( @@ -221,14 +186,12 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/connect", - name: ConnectPage.name, pageBuilder: (context, state) => const SpotubePage( child: ConnectPage(), ), routes: [ GoRoute( path: "control", - name: ConnectControlPage.name, pageBuilder: (context, state) { return const SpotubePage( child: ConnectControlPage(), @@ -239,66 +202,13 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/profile", - name: ProfilePage.name, pageBuilder: (context, state) => const SpotubePage(child: ProfilePage()), - ), - GoRoute( - path: "/stats", - name: StatsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsPage(), - ), - routes: [ - GoRoute( - path: "minutes", - name: StatsMinutesPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsMinutesPage(), - ), - ), - GoRoute( - path: "streams", - name: StatsStreamsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsStreamsPage(), - ), - ), - GoRoute( - path: "fees", - name: StatsStreamFeesPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsStreamFeesPage(), - ), - ), - GoRoute( - path: "artists", - name: StatsArtistsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsArtistsPage(), - ), - ), - GoRoute( - path: "albums", - name: StatsAlbumsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsAlbumsPage(), - ), - ), - GoRoute( - path: "playlists", - name: StatsPlaylistsPage.name, - pageBuilder: (context, state) => const SpotubePage( - child: StatsPlaylistsPage(), - ), - ), - ], ) ], ), GoRoute( path: "/mini-player", - name: MiniLyricsPage.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage( child: MiniLyricsPage(prevSize: state.extra as Size), @@ -306,7 +216,6 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/getting-started", - name: GettingStarting.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( child: GettingStarting(), @@ -314,7 +223,6 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/login", - name: WebViewLogin.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => SpotubePage( child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), @@ -322,7 +230,6 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/login-tutorial", - name: LoginTutorial.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( child: LoginTutorial(), @@ -330,7 +237,6 @@ final routerProvider = Provider((ref) { ), GoRoute( path: "/lastfm-login", - name: LastFMLoginPage.name, parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage(child: LastFMLoginPage()), diff --git a/lib/collections/side_bar_tiles.dart b/lib/collections/side_bar_tiles.dart index 4f23c049..551d70d7 100644 --- a/lib/collections/side_bar_tiles.dart +++ b/lib/collections/side_bar_tiles.dart @@ -1,82 +1,33 @@ import 'package:flutter/material.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/pages/library/library.dart'; -import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/pages/stats/stats.dart'; class SideBarTiles { final IconData icon; final String title; final String id; - final String name; - - SideBarTiles({ - required this.icon, - required this.title, - required this.id, - required this.name, - }); + SideBarTiles({required this.icon, required this.title, required this.id}); } List getSidebarTileList(AppLocalizations l10n) => [ + SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), + SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), SideBarTiles( - id: "browse", - name: HomePage.name, - icon: SpotubeIcons.home, - title: l10n.browse, - ), - SideBarTiles( - id: "search", - name: SearchPage.name, - icon: SpotubeIcons.search, - title: l10n.search, - ), - SideBarTiles( - id: "library", - name: LibraryPage.name, - icon: SpotubeIcons.library, - title: l10n.library, - ), - SideBarTiles( - id: "lyrics", - name: LyricsPage.name, - icon: SpotubeIcons.music, - title: l10n.lyrics, - ), - SideBarTiles( - id: "stats", - name: StatsPage.name, - icon: SpotubeIcons.chart, - title: l10n.stats, - ), + id: "library", icon: SpotubeIcons.library, title: l10n.library), + SideBarTiles(id: "lyrics", icon: SpotubeIcons.music, title: l10n.lyrics), ]; List getNavbarTileList(AppLocalizations l10n) => [ - SideBarTiles( - id: "browse", - name: HomePage.name, - icon: SpotubeIcons.home, - title: l10n.browse, - ), - SideBarTiles( - id: "search", - name: SearchPage.name, - icon: SpotubeIcons.search, - title: l10n.search, - ), + SideBarTiles(id: "browse", icon: SpotubeIcons.home, title: l10n.browse), + SideBarTiles(id: "search", icon: SpotubeIcons.search, title: l10n.search), SideBarTiles( id: "library", - name: LibraryPage.name, icon: SpotubeIcons.library, title: l10n.library, ), SideBarTiles( - id: "stats", - name: StatsPage.name, - icon: SpotubeIcons.chart, - title: l10n.stats, - ), + id: "settings", + icon: SpotubeIcons.settings, + title: l10n.settings, + ) ]; diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index a45e581e..6de21284 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -121,7 +121,4 @@ abstract class SpotubeIcons { static const monitor = FeatherIcons.monitor; static const power = FeatherIcons.power; static const bluetooth = FeatherIcons.bluetooth; - static const chart = FeatherIcons.barChart2; - static const folderAdd = FeatherIcons.folderPlus; - static const folderRemove = FeatherIcons.folderMinus; } diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 7212a574..a71fbf03 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -9,9 +9,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/pages/album/album.dart'; import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -34,7 +32,6 @@ class AlbumCard extends HookConsumerWidget { final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); - final historyNotifier = ref.read(playbackHistoryProvider.notifier); bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), @@ -65,14 +62,7 @@ class AlbumCard extends HookConsumerWidget { description: "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", onTap: () { - ServiceUtils.pushNamed( - context, - AlbumPage.name, - pathParameters: { - "id": album.id!, - }, - extra: album, - ); + ServiceUtils.push(context, "/album/${album.id}", extra: album); }, onPlaybuttonPressed: () async { updating.value = true; @@ -89,15 +79,14 @@ class AlbumCard extends HookConsumerWidget { if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - WebSocketLoadEventData.album( + WebSocketLoadEventData( tracks: fetchedTracks, - collection: album, + collectionId: album.id!, ), ); } else { await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(album.id!); - historyNotifier.addAlbums([album]); } } finally { updating.value = false; @@ -115,7 +104,6 @@ class AlbumCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(album.id!); - historyNotifier.addAlbums([album]); if (context.mounted) { final snackbar = SnackBar( content: Text( diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 9c1ee14a..cc8485d5 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -9,7 +9,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -35,10 +34,6 @@ class ArtistCard extends HookConsumerWidget { final radius = BorderRadius.circular(15); - final bgColor = useBrightnessValue( - theme.colorScheme.surface, - theme.colorScheme.surfaceContainerHigh, - ); final double size = useBreakpointValue( xs: 130, sm: 130, @@ -50,8 +45,12 @@ class ArtistCard extends HookConsumerWidget { width: size, margin: const EdgeInsets.symmetric(vertical: 5), child: Material( - shadowColor: theme.colorScheme.surface, - color: bgColor, + shadowColor: theme.colorScheme.background, + color: Color.lerp( + theme.colorScheme.surfaceVariant, + theme.colorScheme.surface, + useBrightnessValue(.9, .7), + ), elevation: 3, shape: RoundedRectangleBorder( borderRadius: radius, @@ -64,13 +63,7 @@ class ArtistCard extends HookConsumerWidget { ), child: InkWell( onTap: () { - ServiceUtils.pushNamed( - context, - ArtistPage.name, - pathParameters: { - "id": artist.id!, - }, - ); + ServiceUtils.push(context, "/artist/${artist.id}"); }, borderRadius: radius, child: Padding( diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart index f4888534..3ac585df 100644 --- a/lib/components/connect/connect_device.dart +++ b/lib/components/connect/connect_device.dart @@ -3,7 +3,6 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/connect/connect.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -23,7 +22,7 @@ class ConnectDeviceButton extends HookConsumerWidget { width: double.infinity, child: TextButton( onPressed: () { - ServiceUtils.pushNamed(context, ConnectPage.name); + ServiceUtils.push(context, "/connect"); }, style: FilledButton.styleFrom( shape: RoundedRectangleBorder( @@ -60,7 +59,7 @@ class ConnectDeviceButton extends HookConsumerWidget { clipBehavior: Clip.hardEdge, child: InkWell( onTap: () { - ServiceUtils.pushNamed(context, ConnectPage.name); + ServiceUtils.push(context, "/connect"); }, borderRadius: BorderRadius.circular(50), child: Ink( @@ -112,7 +111,7 @@ class ConnectDeviceButton extends HookConsumerWidget { foregroundColor: colorScheme.onPrimary, ), onPressed: () { - ServiceUtils.pushNamed(context, ConnectPage.name); + ServiceUtils.push(context, "/connect"); }, ), ), diff --git a/lib/components/desktop_login/login_form.dart b/lib/components/desktop_login/login_form.dart index 6091829c..2949fbae 100644 --- a/lib/components/desktop_login/login_form.dart +++ b/lib/components/desktop_login/login_form.dart @@ -16,6 +16,7 @@ class TokenLoginForm extends HookConsumerWidget { Widget build(BuildContext context, ref) { final authenticationNotifier = ref.watch(authenticationProvider.notifier); final directCodeController = useTextEditingController(); + final mounted = useIsMounted(); final isLoading = useState(false); @@ -56,7 +57,7 @@ class TokenLoginForm extends HookConsumerWidget { await AuthenticationCredentials.fromCookie( cookieHeader), ); - if (context.mounted) { + if (mounted()) { onDone?.call(); } } finally { diff --git a/lib/components/home/sections/feed.dart b/lib/components/home/sections/feed.dart index f3f632ce..793cd2c3 100644 --- a/lib/components/home/sections/feed.dart +++ b/lib/components/home/sections/feed.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/pages/home/feed/feed_section.dart'; import 'package:spotube/provider/spotify/views/home.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -42,13 +41,8 @@ class HomePageFeedSection extends HookConsumerWidget { child: TextButton.icon( label: const Text("Browse More"), icon: const Icon(SpotubeIcons.angleRight), - onPressed: () => ServiceUtils.pushNamed( - context, - HomeFeedSectionPage.name, - pathParameters: { - "feedId": section.uri, - }, - ), + onPressed: () => + ServiceUtils.push(context, "/feeds/${section.uri}"), ), ), ); diff --git a/lib/components/home/sections/friends.dart b/lib/components/home/sections/friends.dart index 4ae802e6..35ec09b0 100644 --- a/lib/components/home/sections/friends.dart +++ b/lib/components/home/sections/friends.dart @@ -1,14 +1,12 @@ import 'dart:ui'; 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:spotube/collections/fake.dart'; import 'package:spotube/components/home/sections/friends/friend_item.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HomePageFriendsSection extends HookConsumerWidget { @@ -16,7 +14,6 @@ class HomePageFriendsSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final auth = ref.watch(authenticationProvider); final friendsQuery = ref.watch(friendsProvider); final friends = friendsQuery.asData?.value.friends ?? FakeData.friends.friends; @@ -30,36 +27,32 @@ class HomePageFriendsSection extends HookConsumerWidget { xxl: 7, ); - final friendGroup = useMemoized( - () => friends.fold>>( - [], - (previousValue, element) { - if (previousValue.isEmpty) { - return [ - [element] - ]; - } - - final lastGroup = previousValue.last; - if (lastGroup.length < groupCount) { - return [ - ...previousValue.sublist(0, previousValue.length - 1), - [...lastGroup, element] - ]; - } - + final friendGroup = friends.fold>>( + [], + (previousValue, element) { + if (previousValue.isEmpty) { return [ - ...previousValue, [element] ]; - }, - ), - [friends, groupCount], + } + + final lastGroup = previousValue.last; + if (lastGroup.length < groupCount) { + return [ + ...previousValue.sublist(0, previousValue.length - 1), + [...lastGroup, element] + ]; + } + + return [ + ...previousValue, + [element] + ]; + }, ); if (friendsQuery.isLoading || - friendsQuery.asData?.value.friends.isEmpty == true || - auth == null) { + friendsQuery.asData?.value.friends.isEmpty == true) { return const SliverToBoxAdapter( child: SizedBox.shrink(), ); diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/components/home/sections/friends/friend_item.dart index 096964a6..b883e2cc 100644 --- a/lib/components/home/sections/friends/friend_item.dart +++ b/lib/components/home/sections/friends/friend_item.dart @@ -6,9 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/pages/album/album.dart'; -import 'package:spotube/pages/artist/artist.dart'; -import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/spotify_provider.dart'; class FriendItem extends HookConsumerWidget { @@ -30,7 +27,7 @@ class FriendItem extends HookConsumerWidget { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surfaceContainer, + color: colorScheme.surfaceVariant.withOpacity(0.3), borderRadius: BorderRadius.circular(15), ), constraints: const BoxConstraints( @@ -60,9 +57,7 @@ class FriendItem extends HookConsumerWidget { text: friend.track.name, recognizer: TapGestureRecognizer() ..onTap = () { - context.pushNamed(TrackPage.name, pathParameters: { - "id": friend.track.id, - }); + context.push("/track/${friend.track.id}"); }, ), const TextSpan(text: " • "), @@ -76,12 +71,8 @@ class FriendItem extends HookConsumerWidget { text: " ${friend.track.artist.name}", recognizer: TapGestureRecognizer() ..onTap = () { - context.pushNamed( - ArtistPage.name, - pathParameters: { - "id": friend.track.artist.id, - }, - extra: friend.track.artist, + context.push( + "/artist/${friend.track.artist.id}", ); }, ), @@ -114,11 +105,8 @@ class FriendItem extends HookConsumerWidget { final album = await spotify.albums.get(friend.track.album.id); if (context.mounted) { - context.pushNamed( - AlbumPage.name, - pathParameters: { - "id": friend.track.album.id, - }, + context.push( + "/album/${friend.track.album.id}", extra: album, ); } diff --git a/lib/components/home/sections/genres.dart b/lib/components/home/sections/genres.dart index 62f462e2..ac2644f0 100644 --- a/lib/components/home/sections/genres.dart +++ b/lib/components/home/sections/genres.dart @@ -13,8 +13,6 @@ 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/pages/home/genres/genre_playlists.dart'; -import 'package:spotube/pages/home/genres/genres.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HomeGenresSection extends HookConsumerWidget { @@ -52,11 +50,11 @@ class HomeGenresSection extends HookConsumerWidget { textDirection: TextDirection.rtl, child: TextButton.icon( onPressed: () { - context.pushNamed(GenrePage.name); + context.push('/genres'); }, icon: const Icon(SpotubeIcons.angleRight), label: Text( - context.l10n.browse_all, + "Browse All", style: textTheme.bodyMedium?.copyWith( color: colorScheme.secondary, ), @@ -112,13 +110,7 @@ class HomeGenresSection extends HookConsumerWidget { return InkWell( onTap: () { - context.pushNamed( - GenrePlaylistsPage.name, - pathParameters: { - "categoryId": category.id!, - }, - extra: category, - ); + context.push('/genre/${category.id}', extra: category); }, borderRadius: BorderRadius.circular(8), child: Ink( @@ -134,7 +126,7 @@ class HomeGenresSection extends HookConsumerWidget { child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), - color: colorScheme.surfaceContainerHighest, + color: colorScheme.surfaceVariant, gradient: categoriesQuery.isLoading ? null : gradient, ), padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/components/home/sections/recent.dart b/lib/components/home/sections/recent.dart deleted file mode 100644 index 0fc5fadf..00000000 --- a/lib/components/home/sections/recent.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; -import 'package:spotube/provider/history/recent.dart'; -import 'package:spotube/provider/history/state.dart'; - -class HomeRecentlyPlayedSection extends HookConsumerWidget { - const HomeRecentlyPlayedSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final history = ref.watch(recentlyPlayedItems); - - if (history.isEmpty) { - return const SizedBox(); - } - - return HorizontalPlaybuttonCardView( - title: const Text('Recently Played'), - items: [ - for (final item in history) - if (item is PlaybackHistoryPlaylist) - item.playlist - else if (item is PlaybackHistoryAlbum) - item.album - ], - hasNextPage: false, - isLoadingNextPage: false, - onFetchMore: () {}, - ); - } -} diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/components/library/local_folder/local_folder_item.dart deleted file mode 100644 index 6220a967..00000000 --- a/lib/components/library/local_folder/local_folder_item.dart +++ /dev/null @@ -1,199 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:path/path.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/extensions/image.dart'; -import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:spotube/pages/library/local_folder.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -class LocalFolderItem extends HookConsumerWidget { - final String folder; - const LocalFolderItem({super.key, required this.folder}); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:colorScheme) = Theme.of(context); - final mediaQuery = MediaQuery.of(context); - - final lerpValue = useBrightnessValue(.9, .7); - - final downloadFolder = - ref.watch(userPreferencesProvider.select((s) => s.downloadLocation)); - - final isDownloadFolder = folder == downloadFolder; - - final Uri(:pathSegments) = Uri.parse( - folder - .replaceFirst(RegExp(r'^/Volumes/[^/]+/Users/'), "") - .replaceFirst(r'C:\Users\', "") - .replaceFirst(r'/home/', ""), - ); - - // if length > 5, we ... all the middle segments after 2 and the last 2 - final segments = pathSegments.length > 5 - ? [ - ...pathSegments.take(2), - "...", - ...pathSegments.skip(pathSegments.length - 3).toList() - ..removeLast(), - ] - : pathSegments.take(pathSegments.length - 1).toList(); - - final trackSnapshot = ref.watch( - localTracksProvider.select( - (s) => s.whenData((tracks) => tracks[folder]?.take(4).toList()), - ), - ); - - final tracks = trackSnapshot.value ?? []; - - return InkWell( - onTap: () { - context.goNamed( - LocalLibraryPage.name, - queryParameters: { - if (isDownloadFolder) "downloads": "true", - }, - extra: folder, - ); - }, - borderRadius: BorderRadius.circular(8), - child: Ink( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: Color.lerp( - colorScheme.surfaceContainerHighest, - colorScheme.surface, - lerpValue, - ), - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (tracks.isEmpty) - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Icon( - SpotubeIcons.folder, - size: mediaQuery.smAndDown - ? 95 - : mediaQuery.mdAndDown - ? 100 - : 142, - ), - ), - ) - else - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: max((tracks.length / 2).ceil(), 2), - ), - itemCount: tracks.length, - itemBuilder: (context, index) { - final track = tracks[index]; - return UniversalImage( - path: (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - fit: BoxFit.cover, - ); - }, - ), - ), - const Gap(8), - Stack( - children: [ - Center( - child: Text( - isDownloadFolder - ? context.l10n.downloads - : basename(folder), - style: const TextStyle(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - ), - if (!isDownloadFolder) - Align( - alignment: Alignment.topRight, - child: PopupMenuButton( - child: const Padding( - padding: EdgeInsets.all(3), - child: Icon(Icons.more_vert), - ), - itemBuilder: (context) { - return [ - PopupMenuItem( - child: ListTile( - leading: const Icon(SpotubeIcons.folderRemove), - iconColor: colorScheme.error, - title: - Text(context.l10n.remove_library_location), - onTap: () { - final libraryLocations = ref - .read(userPreferencesProvider) - .localLibraryLocation; - ref - .read(userPreferencesProvider.notifier) - .setLocalLibraryLocation( - libraryLocations - .where((e) => e != folder) - .toList(), - ); - }, - ), - ) - ]; - }, - ), - ), - ], - ), - const Spacer(), - Wrap( - spacing: 2, - runSpacing: 2, - children: [ - for (final MapEntry(key: index, value: segment) - in segments.asMap().entries) - Text.rich( - TextSpan( - children: [ - if (index != 0) - TextSpan( - text: "/ ", - style: TextStyle(color: colorScheme.primary), - ), - TextSpan(text: segment), - ], - ), - style: TextStyle( - fontSize: 10, - color: colorScheme.tertiary, - ), - ), - ], - ), - const Spacer(), - ], - ), - ), - ), - ); - } -} diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/components/library/playlist_generate/multi_select_field.dart index d8e0506d..e54fc2ba 100644 --- a/lib/components/library/playlist_generate/multi_select_field.dart +++ b/lib/components/library/playlist_generate/multi_select_field.dart @@ -71,7 +71,7 @@ class MultiSelectField extends HookWidget { : theme.colorScheme.onSurface.withOpacity(0.1), ), ), - mouseCursor: WidgetStateMouseCursor.textable, + mouseCursor: MaterialStateMouseCursor.textable, onPressed: !enabled ? null : () async { diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index c0d63380..a7b2102b 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -1,18 +1,52 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:file_selector/file_selector.dart'; +import 'dart:io'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; +import 'package:collection/collection.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +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/library/local_folder/local_folder_item.dart'; -import 'package:spotube/extensions/constrains.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/sort_tracks_dropdown.dart'; +import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; +import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/service_utils.dart'; // ignore: depend_on_referenced_packages +import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; + +const supportedAudioTypes = [ + "audio/webm", + "audio/ogg", + "audio/mpeg", + "audio/mp4", + "audio/opus", + "audio/wav", + "audio/aac", +]; + +const imgMimeToExt = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", + "image/gif": ".gif", +}; enum SortBy { none, @@ -25,77 +59,273 @@ enum SortBy { album, } +final localTracksProvider = FutureProvider>((ref) async { + try { + if (kIsWeb) return []; + final downloadLocation = ref.watch( + userPreferencesProvider.select((s) => s.downloadLocation), + ); + if (downloadLocation.isEmpty) return []; + final downloadDir = Directory(downloadLocation); + if (!await downloadDir.exists()) { + await downloadDir.create(recursive: true); + return []; + } + final entities = downloadDir.listSync(recursive: true); + + final filesWithMetadata = (await Future.wait( + entities.map((e) => File(e.path)).where((file) { + final mimetype = lookupMimeType(file.path); + return mimetype != null && supportedAudioTypes.contains(mimetype); + }).map( + (file) async { + try { + final metadata = await MetadataGod.readMetadata(file: file.path); + + final imageFile = File(join( + (await getTemporaryDirectory()).path, + "spotube", + basenameWithoutExtension(file.path) + + imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, + )); + if (!await imageFile.exists() && metadata.picture != null) { + await imageFile.create(recursive: true); + await imageFile.writeAsBytes( + metadata.picture?.data ?? [], + mode: FileMode.writeOnly, + ); + } + + return {"metadata": metadata, "file": file, "art": imageFile.path}; + } catch (e, stack) { + if (e is FfiException) { + return {"file": file}; + } + Catcher2.reportCheckedError(e, stack); + return {}; + } + }, + ), + )) + .where((e) => e.isNotEmpty) + .toList(); + + final tracks = filesWithMetadata + .map( + (fileWithMetadata) => LocalTrack.fromTrack( + track: Track().fromFile( + fileWithMetadata["file"], + metadata: fileWithMetadata["metadata"], + art: fileWithMetadata["art"], + ), + path: fileWithMetadata["file"].path, + ), + ) + .toList(); + + return tracks; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + return []; + } +}); + class UserLocalTracks extends HookConsumerWidget { const UserLocalTracks({super.key}); + Future playLocalTracks( + WidgetRef ref, + List tracks, { + LocalTrack? currentTrack, + }) async { + final playlist = ref.read(proxyPlaylistProvider); + final playback = ref.read(proxyPlaylistProvider.notifier); + currentTrack ??= tracks.first; + final isPlaylistPlaying = playlist.containsTracks(tracks); + if (!isPlaylistPlaying) { + await playback.load( + tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playback.jumpToTrack(currentTrack); + } + } + @override Widget build(BuildContext context, ref) { - final preferencesNotifier = ref.watch(userPreferencesProvider.notifier); - final preferences = ref.watch(userPreferencesProvider); + final sortBy = useState(SortBy.none); + final playlist = ref.watch(proxyPlaylistProvider); + final trackSnapshot = ref.watch(localTracksProvider); + final isPlaylistPlaying = + playlist.containsTracks(trackSnapshot.asData?.value ?? []); - final addLocalLibraryLocation = useCallback(() async { - if (kIsMobile || kIsMacOS) { - final dirStr = await FilePicker.platform.getDirectoryPath( - initialDirectory: preferences.downloadLocation, - ); - if (dirStr == null) return; - if (preferences.localLibraryLocation.contains(dirStr)) return; - preferencesNotifier.setLocalLibraryLocation( - [...preferences.localLibraryLocation, dirStr]); - } else { - String? dirStr = await getDirectoryPath( - initialDirectory: preferences.downloadLocation, - ); - if (dirStr == null) return; - if (preferences.localLibraryLocation.contains(dirStr)) return; - preferencesNotifier.setLocalLibraryLocation( - [...preferences.localLibraryLocation, dirStr]); - } - }, [preferences.localLibraryLocation]); + final searchController = useTextEditingController(); + useValueListenable(searchController); + final searchFocus = useFocusNode(); + final isFiltering = useState(false); - // This is just to pre-load the tracks. - // For now, this gets all of them. - ref.watch(localTracksProvider); + final controller = useScrollController(); - return LayoutBuilder(builder: (context, constrains) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Column( - children: [ - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - icon: const Icon(SpotubeIcons.folderAdd), - label: Text(context.l10n.add_library_location), - onPressed: addLocalLibraryLocation, - ), - ), - const Gap(8), - Expanded( - child: GridView.builder( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: constrains.isXs - ? 210 - : constrains.mdAndDown - ? 280 - : 250, - crossAxisSpacing: 10, - mainAxisSpacing: 10, + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const SizedBox(width: 5), + FilledButton( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot.asData?.value.isNotEmpty == true) { + if (!isPlaylistPlaying) { + await playLocalTracks( + ref, + trackSnapshot.asData!.value, + ); + } + } + } + : null, + child: Row( + children: [ + Text(context.l10n.play), + Icon( + isPlaylistPlaying ? SpotubeIcons.stop : SpotubeIcons.play, + ) + ], ), - itemCount: preferences.localLibraryLocation.length + 1, - itemBuilder: (context, index) { - return LocalFolderItem( - folder: index == 0 - ? preferences.downloadLocation - : preferences.localLibraryLocation[index - 1], - ); + ), + const Spacer(), + ExpandableSearchButton( + isFiltering: isFiltering.value, + onPressed: (value) => isFiltering.value = value, + searchFocus: searchFocus, + ), + const SizedBox(width: 10), + SortTracksDropdown( + value: sortBy.value, + onChanged: (value) { + sortBy.value = value; }, ), - ), - ], + const SizedBox(width: 5), + FilledButton( + child: const Icon(SpotubeIcons.refresh), + onPressed: () { + ref.invalidate(localTracksProvider); + }, + ) + ], + ), ), - ); - }); + ExpandableSearchField( + searchController: searchController, + searchFocus: searchFocus, + isFiltering: isFiltering.value, + onChangeFiltering: (value) => isFiltering.value = value, + ), + trackSnapshot.when( + data: (tracks) { + final sortedTracks = useMemoized(() { + return ServiceUtils.sortTracks(tracks, sortBy.value); + }, [sortBy.value, tracks]); + + final filteredTracks = useMemoized(() { + if (searchController.text.isEmpty) { + return sortedTracks; + } + return sortedTracks + .map((e) => ( + weightedRatio( + "${e.name} - ${e.artists?.asString() ?? ""}", + searchController.text, + ), + e, + )) + .toList() + .sorted( + (a, b) => b.$1.compareTo(a.$1), + ) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() + .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 { + ref.invalidate(localTracksProvider); + }, + child: InterScrollbar( + controller: controller, + 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( + playlist: playlist, + track: FakeData.track, + index: index, + ); + } + + final track = filteredTracks[index]; + return TrackTile( + index: index, + playlist: playlist, + track: track, + userPlaylist: false, + onTap: () async { + await playLocalTracks( + ref, + sortedTracks, + currentTrack: track, + ); + }, + ); + }, + ), + ), + ), + ), + ); + }, + loading: () => Expanded( + child: Skeletonizer( + enabled: true, + child: ListView.builder( + itemCount: 5, + itemBuilder: (context, index) => TrackTile( + track: FakeData.track, + index: index, + playlist: playlist, + ), + ), + ), + ), + error: (error, stackTrace) => + Text(error.toString() + stackTrace.toString()), + ) + ], + ); } } diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 1665b3dd..914d7bc9 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -122,8 +122,7 @@ class PlayerQueue extends HookConsumerWidget { top: 5.0, ), decoration: BoxDecoration( - color: - theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), + color: theme.colorScheme.surfaceVariant.withOpacity(0.5), borderRadius: borderRadius, ), child: CallbackShortcuts( diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/components/player/sibling_tracks_sheet.dart index 0575d8eb..99b7b430 100644 --- a/lib/components/player/sibling_tracks_sheet.dart +++ b/lib/components/player/sibling_tracks_sheet.dart @@ -208,8 +208,7 @@ class SiblingTracksSheet extends HookConsumerWidget { : mediaQuery.size.height * .6, decoration: BoxDecoration( borderRadius: borderRadius, - color: - theme.colorScheme.surfaceContainerHighest.withOpacity(.5), + color: theme.colorScheme.surfaceVariant.withOpacity(.5), ), child: Scaffold( backgroundColor: Colors.transparent, diff --git a/lib/components/player/volume_slider.dart b/lib/components/player/volume_slider.dart index 8483143b..102bbef6 100644 --- a/lib/components/player/volume_slider.dart +++ b/lib/components/player/volume_slider.dart @@ -1,5 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -30,17 +31,11 @@ class VolumeSlider extends HookConsumerWidget { } } }, - child: SliderTheme( - data: const SliderThemeData( - showValueIndicator: ShowValueIndicator.always, - ), - child: Slider( - min: 0, - max: 1, - label: (value * 100).toStringAsFixed(0), - value: value, - onChanged: onChanged, - ), + child: Slider( + min: 0, + max: 1, + value: value, + onChanged: onChanged, ), ); return Row( diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 9f26f739..ae6f20e5 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -6,9 +6,7 @@ import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -24,8 +22,6 @@ class PlaylistCard extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlistQueue = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); - final historyNotifier = ref.read(playbackHistoryProvider.notifier); - final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; bool isPlaylistPlaying = useMemoized( @@ -36,23 +32,12 @@ class PlaylistCard extends HookConsumerWidget { final updating = useState(false); final me = ref.watch(meProvider); - Future> fetchInitialTracks() async { + Future> fetchAllTracks() async { if (playlist.id == 'user-liked-tracks') { return await ref.read(likedTracksProvider.future); } - final result = - await ref.read(playlistTracksProvider(playlist.id!).future); - - return result.items; - } - - Future> fetchAllTracks() async { - final initialTracks = await fetchInitialTracks(); - - if (playlist.id == 'user-liked-tracks') { - return initialTracks; - } + await ref.read(playlistTracksProvider(playlist.id!).future); return ref.read(playlistTracksProvider(playlist.id!).notifier).fetchAll(); } @@ -70,12 +55,9 @@ class PlaylistCard extends HookConsumerWidget { isOwner: playlist.owner?.id == me.asData?.value.id && me.asData?.value.id != null, onTap: () { - ServiceUtils.pushNamed( + ServiceUtils.push( context, - PlaylistPage.name, - pathParameters: { - "id": playlist.id!, - }, + "/playlist/${playlist.id}", extra: playlist, ); }, @@ -88,29 +70,22 @@ class PlaylistCard extends HookConsumerWidget { return audioPlayer.resume(); } - final fetchedInitialTracks = await fetchInitialTracks(); + List fetchedTracks = await fetchAllTracks(); - if (fetchedInitialTracks.isEmpty || !context.mounted) return; + if (fetchedTracks.isEmpty || !context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { final remotePlayback = ref.read(connectProvider.notifier); - final allTracks = await fetchAllTracks(); await remotePlayback.load( - WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: playlist, + WebSocketLoadEventData( + tracks: fetchedTracks, + collectionId: playlist.id!, ), ); } else { - await playlistNotifier.load(fetchedInitialTracks, autoPlay: true); + await playlistNotifier.load(fetchedTracks, autoPlay: true); playlistNotifier.addCollection(playlist.id!); - historyNotifier.addPlaylists([playlist]); - - final allTracks = await fetchAllTracks(); - - await playlistNotifier - .addTracks(allTracks.sublist(fetchedInitialTracks.length)); } } finally { if (context.mounted) { @@ -123,22 +98,20 @@ class PlaylistCard extends HookConsumerWidget { try { if (isPlaylistPlaying) return; - final fetchedInitialTracks = await fetchAllTracks(); + final fetchedTracks = await fetchAllTracks(); - if (fetchedInitialTracks.isEmpty) return; + if (fetchedTracks.isEmpty) return; - playlistNotifier.addTracks(fetchedInitialTracks); + playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addCollection(playlist.id!); - historyNotifier.addPlaylists([playlist]); if (context.mounted) { final snackbar = SnackBar( - content: - Text("Added ${fetchedInitialTracks.length} tracks to queue"), + content: Text("Added ${fetchedTracks.length} tracks to queue"), action: SnackBarAction( label: "Undo", onPressed: () { playlistNotifier - .removeTracks(fetchedInitialTracks.map((e) => e.id!)); + .removeTracks(fetchedTracks.map((e) => e.id!)); }, ), ); diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index b99318df..06250131 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -14,6 +15,7 @@ import 'package:spotube/components/player/volume_slider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; @@ -22,7 +24,6 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; class BottomPlayer extends HookConsumerWidget { BottomPlayer({super.key}); @@ -48,6 +49,12 @@ class BottomPlayer extends HookConsumerWidget { ); final theme = Theme.of(context); + final bg = theme.colorScheme.surfaceVariant; + + final bgColor = useBrightnessValue( + Color.lerp(bg, Colors.white, 0.7), + Color.lerp(bg, Colors.black, 0.45)!, + ); // returning an empty non spacious Container as the overlay will take // place in the global overlay stack aka [_entries] @@ -60,9 +67,7 @@ class BottomPlayer extends HookConsumerWidget { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), child: DecoratedBox( - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainer.withOpacity(.8), - ), + decoration: BoxDecoration(color: bgColor?.withOpacity(0.8)), child: Material( type: MaterialType.transparency, textStyle: theme.textTheme.bodyMedium!, @@ -90,19 +95,19 @@ class BottomPlayer extends HookConsumerWidget { tooltip: context.l10n.mini_player, icon: const Icon(SpotubeIcons.miniPlayer), onPressed: () async { - if (!kIsDesktop) return; - - final prevSize = await windowManager.getSize(); - await windowManager.setMinimumSize( + final prevSize = + await DesktopTools.window.getSize(); + await DesktopTools.window.setMinimumSize( const Size(300, 300), ); - await windowManager.setAlwaysOnTop(true); + await DesktopTools.window.setAlwaysOnTop(true); if (!kIsLinux) { - await windowManager.setHasShadow(false); + await DesktopTools.window.setHasShadow(false); } - await windowManager + await DesktopTools.window .setAlignment(Alignment.topRight); - await windowManager.setSize(const Size(400, 500)); + await DesktopTools.window + .setSize(const Size(400, 500)); await Future.delayed( const Duration(milliseconds: 100), () async { diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 4fa14021..a100ca8e 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -14,9 +14,8 @@ import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; -import 'package:spotube/pages/profile/profile.dart'; -import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -27,9 +26,13 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class Sidebar extends HookConsumerWidget { + final int? selectedIndex; + final void Function(int) onSelectedIndexChanged; final Widget child; const Sidebar({ + required this.selectedIndex, + required this.onSelectedIndexChanged, required this.child, super.key, }); @@ -44,9 +47,12 @@ class Sidebar extends HookConsumerWidget { ); } + static void goToSettings(BuildContext context) { + GoRouter.of(context).go("/settings"); + } + @override Widget build(BuildContext context, WidgetRef ref) { - final routerState = GoRouterState.of(context); final mediaQuery = MediaQuery.of(context); final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; @@ -54,22 +60,41 @@ class Sidebar extends HookConsumerWidget { final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); + final controller = useSidebarXController( + selectedIndex: selectedIndex ?? 0, + extended: mediaQuery.lgAndUp, + ); + + final theme = Theme.of(context); + final bg = theme.colorScheme.surfaceVariant; + + final bgColor = useBrightnessValue( + Color.lerp(bg, Colors.white, 0.7), + Color.lerp(bg, Colors.black, 0.45)!, + ); + final sidebarTileList = useMemoized( () => getSidebarTileList(context.l10n), [context.l10n], ); - final selectedIndex = sidebarTileList.indexWhere( - (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, - ); + useEffect(() { + if (controller.selectedIndex != selectedIndex && selectedIndex != null) { + controller.selectIndex(selectedIndex!); + } + return null; + }, [selectedIndex]); - final controller = useSidebarXController( - selectedIndex: selectedIndex, - extended: mediaQuery.lgAndUp, - ); + useEffect(() { + void listener() { + onSelectedIndexChanged(controller.selectedIndex); + } - final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceContainer; + controller.addListener(listener); + return () { + controller.removeListener(listener); + }; + }, [controller]); useEffect(() { if (!context.mounted) return; @@ -81,13 +106,6 @@ class Sidebar extends HookConsumerWidget { return null; }, [mediaQuery, controller]); - useEffect(() { - if (controller.selectedIndex != selectedIndex) { - controller.selectIndex(selectedIndex); - } - return null; - }, [selectedIndex]); - if (layoutMode == LayoutMode.compact || (mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) { return Scaffold(body: child); @@ -101,28 +119,23 @@ class Sidebar extends HookConsumerWidget { items: sidebarTileList.mapIndexed( (index, e) { return SidebarXItem( - onTap: () { - context.goNamed(e.name); - }, - iconBuilder: (selected, hovered) { - return Badge( - backgroundColor: theme.colorScheme.primary, - isLabelVisible: e.title == "Library" && downloadCount > 0, - label: Text( - downloadCount.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), + iconWidget: Badge( + backgroundColor: theme.colorScheme.primary, + isLabelVisible: e.title == "Library" && downloadCount > 0, + label: Text( + downloadCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, ), - child: Icon( - e.icon, - color: selected || hovered - ? theme.colorScheme.primary - : null, - ), - ); - }, + ), + child: Icon( + e.icon, + color: selectedIndex == index + ? theme.colorScheme.primary + : null, + ), + ), label: e.title, ); }, @@ -153,7 +166,7 @@ class Sidebar extends HookConsumerWidget { ), padding: const EdgeInsets.symmetric(horizontal: 6), decoration: BoxDecoration( - color: bg, + color: bgColor?.withOpacity(0.8), borderRadius: const BorderRadius.only( topRight: Radius.circular(10), bottomRight: Radius.circular(10), @@ -244,7 +257,7 @@ class SidebarFooter extends HookConsumerWidget { if (mediaQuery.mdAndDown) { return IconButton( icon: const Icon(SpotubeIcons.settings), - onPressed: () => ServiceUtils.navigateNamed(context, SettingsPage.name), + onPressed: () => Sidebar.goToSettings(context), ); } @@ -265,7 +278,7 @@ class SidebarFooter extends HookConsumerWidget { Flexible( child: InkWell( onTap: () { - ServiceUtils.pushNamed(context, ProfilePage.name); + ServiceUtils.push(context, "/profile"); }, borderRadius: BorderRadius.circular(30), child: Row( @@ -297,7 +310,7 @@ class SidebarFooter extends HookConsumerWidget { IconButton( icon: const Icon(SpotubeIcons.settings), onPressed: () { - ServiceUtils.pushNamed(context, SettingsPage.name); + Sidebar.goToSettings(context); }, ), ], diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 3d0c7c75..489399e5 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -3,54 +3,55 @@ import 'dart:ui'; import 'package:curved_navigation_bar/curved_navigation_bar.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:spotube/collections/side_bar_tiles.dart'; +import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/utils/service_utils.dart'; final navigationPanelHeight = StateProvider((ref) => 50); class SpotubeNavigationBar extends HookConsumerWidget { + final int? selectedIndex; + final void Function(int) onSelectedIndexChanged; + const SpotubeNavigationBar({ + required this.selectedIndex, + required this.onSelectedIndexChanged, super.key, }); @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final routerState = GoRouterState.of(context); - final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; final mediaQuery = MediaQuery.of(context); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); + final insideSelectedIndex = useState(selectedIndex ?? 0); + final buttonColor = useBrightnessValue( theme.colorScheme.inversePrimary, theme.colorScheme.primary.withOpacity(0.2), ); - final navbarTileList = useMemoized( - () => getNavbarTileList(context.l10n), - [context.l10n], - ); + final navbarTileList = + useMemoized(() => getNavbarTileList(context.l10n), [context.l10n]); final panelHeight = ref.watch(navigationPanelHeight); - final selectedIndex = useMemoized(() { - final index = navbarTileList.indexWhere( - (e) => routerState.namedLocation(e.name) == routerState.matchedLocation, - ); - - return index == -1 ? 0 : index; - }, [navbarTileList, routerState.matchedLocation]); + useEffect(() { + if (selectedIndex != null) { + insideSelectedIndex.value = selectedIndex!; + } + return null; + }, [selectedIndex]); if (layoutMode == LayoutMode.extended || (mediaQuery.mdAndUp && layoutMode == LayoutMode.adaptive) || @@ -68,7 +69,7 @@ class SpotubeNavigationBar extends HookConsumerWidget { backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.72), buttonBackgroundColor: buttonColor, - color: theme.colorScheme.surface, + color: theme.colorScheme.background, height: panelHeight, animationDuration: const Duration(milliseconds: 350), items: navbarTileList.map( @@ -90,9 +91,14 @@ class SpotubeNavigationBar extends HookConsumerWidget { }); }, ).toList(), - index: selectedIndex, + index: insideSelectedIndex.value, onTap: (i) { - ServiceUtils.navigateNamed(context, navbarTileList[i].name); + insideSelectedIndex.value = i; + if (navbarTileList[i].id == "settings") { + Sidebar.goToSettings(context); + return; + } + onSelectedIndexChanged(i); }, ), ), diff --git a/lib/components/root/update_dialog.dart b/lib/components/root/update_dialog.dart deleted file mode 100644 index e15903c6..00000000 --- a/lib/components/root/update_dialog.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; -import 'package:url_launcher/url_launcher_string.dart'; -import 'package:version/version.dart'; - -class RootAppUpdateDialog extends StatelessWidget { - final Version? version; - final int? nightlyBuildNum; - - const RootAppUpdateDialog({super.key, this.version}) : nightlyBuildNum = null; - const RootAppUpdateDialog.nightly({super.key, required this.nightlyBuildNum}) - : version = null; - - @override - Widget build(BuildContext context) { - const url = "https://spotube.krtirtho.dev/downloads"; - const nightlyUrl = "https://spotube.krtirtho.dev/downloads/nightly"; - return AlertDialog( - title: const Text("Spotube has an update"), - actions: [ - FilledButton( - child: const Text("Download Now"), - onPressed: () => launchUrlString( - nightlyBuildNum != null ? nightlyUrl : url, - mode: LaunchMode.externalApplication, - ), - ), - ], - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - nightlyBuildNum != null - ? "Spotube Nightly $nightlyBuildNum has been released" - : "Spotube v$version has been released", - ), - if (nightlyBuildNum == null) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("Read the latest "), - AnchorButton( - "release notes", - style: const TextStyle(color: Colors.blue), - onTap: () => launchUrlString( - url, - mode: LaunchMode.externalApplication, - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/components/settings/color_scheme_picker_dialog.dart index 579f5a29..8d098375 100644 --- a/lib/components/settings/color_scheme_picker_dialog.dart +++ b/lib/components/settings/color_scheme_picker_dialog.dart @@ -179,9 +179,9 @@ class ColorTile extends StatelessWidget { colorScheme.primaryContainer, colorScheme.secondary, colorScheme.secondaryContainer, + colorScheme.background, colorScheme.surface, - colorScheme.surface, - colorScheme.surfaceContainerHighest, + colorScheme.surfaceVariant, colorScheme.onPrimary, colorScheme.onSurface, ]; diff --git a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart index ce7d3b8c..21f56a22 100644 --- a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart @@ -187,7 +187,7 @@ class AdaptivePopSheetList extends StatelessWidget { icon: icon ?? const Icon(SpotubeIcons.moreVertical), tooltip: tooltip, style: theme.iconButtonTheme.style?.copyWith( - shape: WidgetStatePropertyAll( + shape: MaterialStatePropertyAll( RoundedRectangleBorder( borderRadius: borderRadius, ), diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/shared/fallbacks/anonymous_fallback.dart index 5ced6bb6..2f06b0b6 100644 --- a/lib/components/shared/fallbacks/anonymous_fallback.dart +++ b/lib/components/shared/fallbacks/anonymous_fallback.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -26,7 +25,7 @@ class AnonymousFallback extends ConsumerWidget { const SizedBox(height: 10), FilledButton( child: Text(context.l10n.login_with_spotify), - onPressed: () => ServiceUtils.pushNamed(context, SettingsPage.name), + onPressed: () => ServiceUtils.push(context, "/settings"), ) ], ), diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index 291950bb..e142cb35 100644 --- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -96,7 +96,7 @@ class HorizontalPlaybuttonCardView extends HookWidget { return switch (item) { PlaylistSimple() => PlaylistCard(item as PlaylistSimple), - AlbumSimple() => AlbumCard(item as AlbumSimple), + AlbumSimple() => AlbumCard(item as Album), Artist() => Padding( padding: const EdgeInsets.symmetric( horizontal: 12.0), diff --git a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart index 8a86b643..2b3ce319 100644 --- a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart +++ b/lib/components/shared/inter_scrollbar/inter_scrollbar.dart @@ -1,7 +1,7 @@ import 'package:draggable_scrollbar/draggable_scrollbar.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/utils/platform.dart'; class InterScrollbar extends HookWidget { final Widget child; @@ -15,7 +15,7 @@ class InterScrollbar extends HookWidget { @override Widget build(BuildContext context) { - if (kIsDesktop) return child; + if (DesktopTools.platform.isDesktop) return child; return DraggableScrollbar.semicircle( controller: controller, diff --git a/lib/components/shared/links/anchor_button.dart b/lib/components/shared/links/anchor_button.dart index c6f0b889..d78bbf96 100644 --- a/lib/components/shared/links/anchor_button.dart +++ b/lib/components/shared/links/anchor_button.dart @@ -29,7 +29,7 @@ class AnchorButton extends HookWidget { onTapUp: (event) => tap.value = false, onTap: onTap, child: MouseRegion( - cursor: WidgetStateMouseCursor.clickable, + cursor: MaterialStateMouseCursor.clickable, child: Text( text, style: style.copyWith( diff --git a/lib/components/shared/links/artist_link.dart b/lib/components/shared/links/artist_link.dart index 5236a061..af8b186a 100644 --- a/lib/components/shared/links/artist_link.dart +++ b/lib/components/shared/links/artist_link.dart @@ -1,7 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/links/anchor_button.dart'; -import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/utils/service_utils.dart'; class ArtistLink extends StatelessWidget { @@ -41,12 +40,9 @@ class ArtistLink extends StatelessWidget { if (onRouteChange != null) { onRouteChange?.call("/artist/${artist.value.id}"); } else { - ServiceUtils.pushNamed( + ServiceUtils.push( context, - ArtistPage.name, - pathParameters: { - "id": artist.value.id!, - }, + "/artist/${artist.value.id}", ); } }, diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/shared/page_window_title_bar.dart index 573c7c47..37daefa9 100644 --- a/lib/components/shared/page_window_title_bar.dart +++ b/lib/components/shared/page_window_title_bar.dart @@ -7,8 +7,7 @@ import 'package:titlebar_buttons/titlebar_buttons.dart'; import 'dart:math'; import 'package:flutter/foundation.dart' show kIsWeb; import 'dart:io' show Platform; - -import 'package:window_manager/window_manager.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; class PageWindowTitleBar extends StatefulHookConsumerWidget implements PreferredSizeWidget { @@ -90,7 +89,7 @@ class _PageWindowTitleBarState extends ConsumerState { final systemTitleBar = ref.read(userPreferencesProvider.select((s) => s.systemTitleBar)); if (kIsDesktop && !systemTitleBar) { - windowManager.startDragging(); + DesktopTools.window.startDragging(); } } @@ -108,7 +107,11 @@ class _PageWindowTitleBarState extends ConsumerState { return SliverPadding( padding: EdgeInsets.only( - left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, + left: DesktopTools.platform.isMacOS && + hasFullscreen && + hasLeadingOrCanPop + ? 65 + : 0, ), sliver: SliverAppBar( leading: widget.leading, @@ -146,7 +149,11 @@ class _PageWindowTitleBarState extends ConsumerState { onVerticalDragStart: onDrag, child: Padding( padding: EdgeInsets.only( - left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, + left: DesktopTools.platform.isMacOS && + hasFullscreen && + hasLeadingOrCanPop + ? 65 + : 0, ), child: AppBar( leading: widget.leading, @@ -165,10 +172,6 @@ class _PageWindowTitleBarState extends ConsumerState { toolbarTextStyle: widget.toolbarTextStyle, titleTextStyle: widget.titleTextStyle, title: widget.title, - scrolledUnderElevation: 0, - shadowColor: Colors.transparent, - forceMaterialTransparency: true, - elevation: 0, ), ), ); @@ -190,12 +193,12 @@ class WindowTitleBarButtons extends HookConsumerWidget { const type = ThemeType.auto; Future onClose() async { - await windowManager.close(); + await DesktopTools.window.close(); } useEffect(() { if (kIsDesktop) { - windowManager.isMaximized().then((value) { + DesktopTools.window.isMaximized().then((value) { isMaximized.value = value; }); } @@ -210,16 +213,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { final theme = Theme.of(context); final colors = WindowButtonColors( normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onSurface, - mouseOver: theme.colorScheme.onSurface.withOpacity(0.1), - mouseDown: theme.colorScheme.onSurface.withOpacity(0.2), - iconMouseOver: theme.colorScheme.onSurface, - iconMouseDown: theme.colorScheme.onSurface, + iconNormal: foregroundColor ?? theme.colorScheme.onBackground, + mouseOver: theme.colorScheme.onBackground.withOpacity(0.1), + mouseDown: theme.colorScheme.onBackground.withOpacity(0.2), + iconMouseOver: theme.colorScheme.onBackground, + iconMouseDown: theme.colorScheme.onBackground, ); final closeColors = WindowButtonColors( normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onSurface, + iconNormal: foregroundColor ?? theme.colorScheme.onBackground, mouseOver: Colors.red, mouseDown: Colors.red[800]!, iconMouseOver: Colors.white, @@ -232,14 +235,14 @@ class WindowTitleBarButtons extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ MinimizeWindowButton( - onPressed: windowManager.minimize, + onPressed: DesktopTools.window.minimize, colors: colors, ), if (isMaximized.value != true) MaximizeWindowButton( colors: colors, onPressed: () { - windowManager.maximize(); + DesktopTools.window.maximize(); isMaximized.value = true; }, ) @@ -247,7 +250,7 @@ class WindowTitleBarButtons extends HookConsumerWidget { RestoreWindowButton( colors: colors, onPressed: () { - windowManager.unmaximize(); + DesktopTools.window.unmaximize(); isMaximized.value = false; }, ), @@ -267,16 +270,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { children: [ DecoratedMinimizeButton( type: type, - onPressed: windowManager.minimize, + onPressed: DesktopTools.window.minimize, ), DecoratedMaximizeButton( type: type, onPressed: () async { - if (await windowManager.isMaximized()) { - await windowManager.unmaximize(); + if (await DesktopTools.window.isMaximized()) { + await DesktopTools.window.unmaximize(); isMaximized.value = false; } else { - await windowManager.maximize(); + await DesktopTools.window.maximize(); isMaximized.value = true; } }, diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index 807628b3..80a27eb0 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -53,10 +53,6 @@ class PlaybuttonCard extends HookWidget { final mediaQuery = MediaQuery.of(context); final radius = BorderRadius.circular(15); - final bgColor = useBrightnessValue( - theme.colorScheme.surface, - theme.colorScheme.surfaceContainerHigh, - ); final double size = useBreakpointValue( xs: 130, sm: 130, @@ -76,9 +72,13 @@ class PlaybuttonCard extends HookWidget { constraints: BoxConstraints(maxWidth: size), margin: margin, child: Material( - color: bgColor, + color: Color.lerp( + theme.colorScheme.surfaceVariant, + theme.colorScheme.surface, + useBrightnessValue(.9, .7), + ), borderRadius: radius, - shadowColor: theme.colorScheme.surface, + shadowColor: theme.colorScheme.background, elevation: 3, child: InkWell( mouseCursor: SystemMouseCursors.click, @@ -158,7 +158,7 @@ class PlaybuttonCard extends HookWidget { Skeleton.keep( child: IconButton( style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.surface, + backgroundColor: theme.colorScheme.background, foregroundColor: theme.colorScheme.primary, minimumSize: const Size.square(10), ), diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/shared/themed_button_tab_bar.dart index c245e5f4..017f04aa 100644 --- a/lib/components/shared/themed_button_tab_bar.dart +++ b/lib/components/shared/themed_button_tab_bar.dart @@ -5,8 +5,7 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { final List tabs; - final TabController? controller; - const ThemedButtonsTabBar({super.key, required this.tabs, this.controller}); + const ThemedButtonsTabBar({super.key, required this.tabs}); @override Widget build(BuildContext context) { @@ -22,7 +21,6 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { bottom: 8, ), child: ButtonsTabBar( - controller: controller, radius: 100, decoration: BoxDecoration( color: bgColor, @@ -34,7 +32,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { ), borderWidth: 0, unselectedDecoration: BoxDecoration( - color: theme.colorScheme.surface, + color: theme.colorScheme.background, borderRadius: BorderRadius.circular(15), ), unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith( diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/shared/track_tile/track_options.dart index 4b383c47..a9ec36b9 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/shared/track_tile/track_options.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; @@ -22,7 +23,6 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; @@ -197,8 +197,6 @@ class TrackOptions extends HookConsumerWidget { return downloadManager.getProgressNotifier(spotubeTrack); }); - final isLocalTrack = track is LocalTrack; - final adaptivePopSheetList = AdaptivePopSheetList( onSelected: (value) async { switch (value) { @@ -316,120 +314,118 @@ class TrackOptions extends HookConsumerWidget { ), ), ], - children: [ - if (isLocalTrack) - PopSheetEntry( - value: TrackOptionValue.delete, - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), - ), - 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, - leading: const Icon(SpotubeIcons.queueAdd), - title: Text(context.l10n.add_to_queue), - ), - PopSheetEntry( - value: TrackOptionValue.playNext, - leading: const Icon(SpotubeIcons.lightning), - title: Text(context.l10n.play_next), - ), - ] else - PopSheetEntry( - value: TrackOptionValue.removeFromQueue, - enabled: playlist.activeTrack?.id != track.id, - leading: const Icon(SpotubeIcons.queueRemove), - title: Text(context.l10n.remove_from_queue), - ), - if (me.asData?.value != null && !isLocalTrack) - PopSheetEntry( - value: TrackOptionValue.favorite, - leading: favorites.isLiked - ? const Icon( - SpotubeIcons.heartFilled, - color: Colors.pink, - ) - : const Icon(SpotubeIcons.heart), - title: Text( - favorites.isLiked - ? context.l10n.remove_from_favorites - : context.l10n.save_as_favorite, + children: switch (track.runtimeType) { + LocalTrack() => [ + PopSheetEntry( + value: TrackOptionValue.delete, + leading: const Icon(SpotubeIcons.trash), + title: Text(context.l10n.delete), + ) + ], + _ => [ + 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, + leading: const Icon(SpotubeIcons.queueAdd), + title: Text(context.l10n.add_to_queue), + ), + PopSheetEntry( + value: TrackOptionValue.playNext, + leading: const Icon(SpotubeIcons.lightning), + title: Text(context.l10n.play_next), + ), + ] else + PopSheetEntry( + value: TrackOptionValue.removeFromQueue, + enabled: playlist.activeTrack?.id != track.id, + leading: const Icon(SpotubeIcons.queueRemove), + title: Text(context.l10n.remove_from_queue), + ), + if (me.asData?.value != null) + PopSheetEntry( + value: TrackOptionValue.favorite, + leading: favorites.isLiked + ? const Icon( + SpotubeIcons.heartFilled, + color: Colors.pink, + ) + : const Icon(SpotubeIcons.heart), + title: Text( + favorites.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, + ), + ), + if (auth != null) ...[ + PopSheetEntry( + value: TrackOptionValue.startRadio, + leading: const Icon(SpotubeIcons.radio), + title: Text(context.l10n.start_a_radio), + ), + PopSheetEntry( + value: TrackOptionValue.addToPlaylist, + leading: const Icon(SpotubeIcons.playlistAdd), + title: Text(context.l10n.add_to_playlist), + ), + ], + if (userPlaylist && auth != null) + PopSheetEntry( + value: TrackOptionValue.removeFromPlaylist, + leading: const Icon(SpotubeIcons.removeFilled), + title: Text(context.l10n.remove_from_playlist), + ), + PopSheetEntry( + value: TrackOptionValue.download, + enabled: !isInQueue, + leading: isInQueue + ? HookBuilder(builder: (context) { + final progress = useListenable(progressNotifier!); + return CircularProgressIndicator( + value: progress.value, + ); + }) + : const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_track), ), - ), - if (auth != null && !isLocalTrack) ...[ - PopSheetEntry( - value: TrackOptionValue.startRadio, - leading: const Icon(SpotubeIcons.radio), - title: Text(context.l10n.start_a_radio), - ), - PopSheetEntry( - value: TrackOptionValue.addToPlaylist, - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - ), - ], - if (userPlaylist && auth != null && !isLocalTrack) - PopSheetEntry( - value: TrackOptionValue.removeFromPlaylist, - leading: const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), - ), - if (!isLocalTrack) - PopSheetEntry( - value: TrackOptionValue.download, - enabled: !isInQueue, - leading: isInQueue - ? HookBuilder(builder: (context) { - final progress = useListenable(progressNotifier!); - return CircularProgressIndicator( - value: progress.value, - ); - }) - : const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_track), - ), - if (!isLocalTrack) - PopSheetEntry( - value: TrackOptionValue.blacklist, - leading: const Icon(SpotubeIcons.playlistRemove), - iconColor: !isBlackListed ? Colors.red[400] : null, - textColor: !isBlackListed ? Colors.red[400] : null, - title: Text( - isBlackListed - ? context.l10n.remove_from_blacklist - : context.l10n.add_to_blacklist, + PopSheetEntry( + value: TrackOptionValue.blacklist, + leading: const Icon(SpotubeIcons.playlistRemove), + iconColor: !isBlackListed ? Colors.red[400] : null, + textColor: !isBlackListed ? Colors.red[400] : null, + title: Text( + isBlackListed + ? context.l10n.remove_from_blacklist + : context.l10n.add_to_blacklist, + ), ), - ), - if (!isLocalTrack) - PopSheetEntry( - value: TrackOptionValue.share, - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), - ), - if (!isLocalTrack) - PopSheetEntry( - value: TrackOptionValue.songlink, - leading: Assets.logos.songlinkTransparent.image( - width: 22, - height: 22, - color: colorScheme.onSurface.withOpacity(0.5), + PopSheetEntry( + value: TrackOptionValue.share, + leading: const Icon(SpotubeIcons.share), + title: Text(context.l10n.share), ), - title: Text(context.l10n.song_link), - ), - if (!isLocalTrack) - PopSheetEntry( - value: TrackOptionValue.details, - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.details), - ), - ], + PopSheetEntry( + value: TrackOptionValue.songlink, + leading: Assets.logos.songlinkTransparent.image( + width: 22, + height: 22, + color: colorScheme.onSurface.withOpacity(0.5), + ), + title: Text(context.l10n.song_link), + ), + PopSheetEntry( + value: TrackOptionValue.details, + leading: const Icon(SpotubeIcons.info), + title: Text(context.l10n.details), + ), + ] + }, ); //! This is the most ANTI pattern I've ever done, but it works diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/shared/track_tile/track_tile.dart index e3aea4de..30912da2 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/shared/track_tile/track_tile.dart @@ -195,26 +195,19 @@ class TrackTile extends HookConsumerWidget { children: [ Expanded( flex: 6, - child: switch (track) { - LocalTrack() => Text( - track.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - _ => LinkText( - track.name!, - "/track/${track.id}", - push: true, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - }, + child: LinkText( + track.name!, + "/track/${track.id}", + push: true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), if (constrains.mdAndUp) ...[ const SizedBox(width: 8), Expanded( flex: 4, - child: switch (track) { + child: switch (track.runtimeType) { LocalTrack() => Text( track.album!.name!, maxLines: 1, diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index c3605f33..f576ba0a 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -17,7 +17,6 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @@ -29,7 +28,6 @@ class TrackViewBodySection extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final props = InheritedTrackView.of(context); final trackViewState = ref.watch(trackViewProvider(props.tracks)); @@ -148,17 +146,11 @@ class TrackViewBodySection extends HookConsumerWidget { } else { final tracks = await props.pagination.onFetchAll(); await remotePlayback.load( - props.collection is AlbumSimple - ? WebSocketLoadEventData.album( - tracks: tracks, - collection: props.collection as AlbumSimple, - initialIndex: index, - ) - : WebSocketLoadEventData.playlist( - tracks: tracks, - collection: props.collection as PlaylistSimple, - initialIndex: index, - ), + WebSocketLoadEventData( + tracks: tracks, + collectionId: props.collectionId, + initialIndex: index, + ), ); } } else { @@ -172,13 +164,6 @@ class TrackViewBodySection extends HookConsumerWidget { autoPlay: true, ); playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier - .addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } } } }, diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/shared/tracks_view/sections/body/track_view_options.dart index c2adf38b..ff92b663 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_options.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; @@ -9,7 +8,6 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; @@ -25,7 +23,6 @@ class TrackViewBodyOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final audioSource = ref.watch(userPreferencesProvider.select((s) => s.audioSource)); @@ -75,12 +72,6 @@ class TrackViewBodyOptions extends HookConsumerWidget { { playlistNotifier.addTracksAtFirst(selectedTracks); playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } trackViewState.deselectAll(); break; } @@ -88,12 +79,6 @@ class TrackViewBodyOptions extends HookConsumerWidget { { playlistNotifier.addTracks(selectedTracks); playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } trackViewState.deselectAll(); break; } diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/shared/tracks_view/sections/header/flexible_header.dart index d6e71e8f..4a704302 100644 --- a/lib/components/shared/tracks_view/sections/header/flexible_header.dart +++ b/lib/components/shared/tracks_view/sections/header/flexible_header.dart @@ -1,7 +1,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; - +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; @@ -12,7 +12,6 @@ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:gap/gap.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; -import 'package:spotube/utils/platform.dart'; class TrackViewFlexHeader extends HookConsumerWidget { const TrackViewFlexHeader({super.key}); @@ -54,7 +53,7 @@ class TrackViewFlexHeader extends HookConsumerWidget { floating: false, pinned: true, expandedHeight: 450, - automaticallyImplyLeading: kIsMobile, + automaticallyImplyLeading: DesktopTools.platform.isMobile, backgroundColor: palette.color, title: isExpanded ? null : Text(props.title, style: headingStyle), flexibleSpace: FlexibleSpaceBar( diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart index 8c1c8e15..f6880485 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/heart_button.dart'; @@ -10,7 +9,6 @@ import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_ import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; class TrackViewHeaderActions extends HookConsumerWidget { @@ -22,7 +20,6 @@ class TrackViewHeaderActions extends HookConsumerWidget { final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); @@ -64,13 +61,6 @@ class TrackViewHeaderActions extends HookConsumerWidget { final tracks = await props.pagination.onFetchAll(); await playlistNotifier.addTracks(tracks); playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier - .addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } }, ), if (props.onHeart != null && auth != null) diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index 5cc442cf..50eeb747 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -5,14 +5,12 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -30,7 +28,6 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final props = InheritedTrackView.of(context); final playlist = ref.watch(proxyPlaylistProvider); final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); @@ -47,45 +44,28 @@ class TrackViewHeaderButtons extends HookConsumerWidget { try { isLoading.value = true; - final initialTracks = props.tracks; + final allTracks = await props.pagination.onFetchAll(); + if (!context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { - final allTracks = await props.pagination.onFetchAll(); final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - props.collection is AlbumSimple - ? WebSocketLoadEventData.album( - tracks: allTracks, - collection: props.collection as AlbumSimple, - initialIndex: Random().nextInt(allTracks.length)) - : WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: props.collection as PlaylistSimple, - initialIndex: Random().nextInt(allTracks.length), - ), + WebSocketLoadEventData( + tracks: allTracks, + collectionId: props.collectionId, + initialIndex: Random().nextInt(allTracks.length)), ); await remotePlayback.setShuffle(true); } else { await playlistNotifier.load( - initialTracks, + allTracks, autoPlay: true, - initialIndex: Random().nextInt(initialTracks.length), + initialIndex: Random().nextInt(allTracks.length), ); await audioPlayer.setShuffle(true); playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier.addPlaylists([props.collection as PlaylistSimple]); - } - - final allTracks = await props.pagination.onFetchAll(); - - await playlistNotifier.addTracks( - allTracks.sublist(initialTracks.length), - ); } } finally { isLoading.value = false; @@ -96,39 +76,22 @@ class TrackViewHeaderButtons extends HookConsumerWidget { try { isLoading.value = true; - final initialTracks = props.tracks; + final allTracks = await props.pagination.onFetchAll(); if (!context.mounted) return; final isRemoteDevice = await showSelectDeviceDialog(context, ref); if (isRemoteDevice) { - final allTracks = await props.pagination.onFetchAll(); final remotePlayback = ref.read(connectProvider.notifier); await remotePlayback.load( - props.collection is AlbumSimple - ? WebSocketLoadEventData.album( - tracks: allTracks, - collection: props.collection as AlbumSimple, - ) - : WebSocketLoadEventData.playlist( - tracks: allTracks, - collection: props.collection as PlaylistSimple, - ), + WebSocketLoadEventData( + tracks: allTracks, + collectionId: props.collectionId, + ), ); } else { - await playlistNotifier.load(initialTracks, autoPlay: true); + await playlistNotifier.load(allTracks, autoPlay: true); playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier.addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier.addPlaylists([props.collection as PlaylistSimple]); - } - - final allTracks = await props.pagination.onFetchAll(); - - await playlistNotifier.addTracks( - allTracks.sublist(initialTracks.length), - ); } } finally { isLoading.value = false; diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/shared/tracks_view/track_view.dart index 03d628a8..eb8f6871 100644 --- a/lib/components/shared/tracks_view/track_view.dart +++ b/lib/components/shared/tracks_view/track_view.dart @@ -1,5 +1,5 @@ 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:sliver_tools/sliver_tools.dart'; @@ -8,7 +8,6 @@ import 'package:spotube/components/shared/page_window_title_bar.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'; -import 'package:spotube/utils/platform.dart'; class TrackView extends HookConsumerWidget { const TrackView({super.key}); @@ -19,7 +18,7 @@ class TrackView extends HookConsumerWidget { final controller = useScrollController(); return Scaffold( - appBar: kIsDesktop + appBar: DesktopTools.platform.isDesktop ? const PageWindowTitleBar( backgroundColor: Colors.transparent, foregroundColor: Colors.white, diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/shared/tracks_view/track_view_props.dart index b0a00ae2..a1a07f84 100644 --- a/lib/components/shared/tracks_view/track_view_props.dart +++ b/lib/components/shared/tracks_view/track_view_props.dart @@ -39,7 +39,7 @@ class PaginationProps { } class InheritedTrackView extends InheritedWidget { - final Object collection; + final String collectionId; final String title; final String? description; final String image; @@ -55,7 +55,7 @@ class InheritedTrackView extends InheritedWidget { const InheritedTrackView({ super.key, required super.child, - required this.collection, + required this.collectionId, required this.title, this.description, required this.image, @@ -65,11 +65,7 @@ class InheritedTrackView extends InheritedWidget { required this.shareUrl, this.isLiked = false, this.onHeart, - }) : assert(collection is AlbumSimple || collection is PlaylistSimple); - - String get collectionId => collection is AlbumSimple - ? (collection as AlbumSimple).id! - : (collection as PlaylistSimple).id!; + }); @override bool updateShouldNotify(InheritedTrackView oldWidget) { @@ -82,7 +78,7 @@ class InheritedTrackView extends InheritedWidget { oldWidget.onHeart != onHeart || oldWidget.shareUrl != shareUrl || oldWidget.routePath != routePath || - oldWidget.collection != collection || + oldWidget.collectionId != collectionId || oldWidget.child != child; } diff --git a/lib/components/shared/waypoint.dart b/lib/components/shared/waypoint.dart index cf00e29b..08e9088a 100644 --- a/lib/components/shared/waypoint.dart +++ b/lib/components/shared/waypoint.dart @@ -20,6 +20,8 @@ class Waypoint extends HookWidget { @override Widget build(BuildContext context) { + final isMounted = useIsMounted(); + useEffect(() { if (isGrid) { return null; @@ -30,19 +32,19 @@ class Waypoint extends HookWidget { // scrollController fetches the next paginated data when the current // position of the user on the screen has surpassed - if (controller.position.pixels >= nextPageTrigger && context.mounted) { + if (controller.position.pixels >= nextPageTrigger && isMounted()) { await onTouchEdge?.call(); } } WidgetsBinding.instance.addPostFrameCallback((_) { - if (controller.hasClients && context.mounted) { + if (controller.hasClients && isMounted()) { listener(); controller.addListener(listener); } }); return () => controller.removeListener(listener); - }, [controller, onTouchEdge]); + }, [controller, onTouchEdge, isMounted]); if (isGrid) { return VisibilityDetector( diff --git a/lib/components/stats/common/album_item.dart b/lib/components/stats/common/album_item.dart deleted file mode 100644 index 00b1cbfe..00000000 --- a/lib/components/stats/common/album_item.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/album/album_card.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/album/album.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class StatsAlbumItem extends StatelessWidget { - final AlbumSimple album; - final Widget info; - const StatsAlbumItem({super.key, required this.album, required this.info}); - - @override - Widget build(BuildContext context) { - return ListTile( - horizontalTitleGap: 8, - leading: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: UniversalImage( - path: (album.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - width: 40, - height: 40, - ), - ), - title: Text(album.name!), - subtitle: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text("${album.albumType?.formatted} • "), - Flexible( - child: ArtistLink( - artists: album.artists ?? [], - mainAxisAlignment: WrapAlignment.start, - ), - ), - ], - ), - trailing: info, - onTap: () { - ServiceUtils.pushNamed( - context, - AlbumPage.name, - pathParameters: {"id": album.id!}, - extra: album, - ); - }, - ); - } -} diff --git a/lib/components/stats/common/artist_item.dart b/lib/components/stats/common/artist_item.dart deleted file mode 100644 index 9282d4e1..00000000 --- a/lib/components/stats/common/artist_item.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/artist/artist.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class StatsArtistItem extends StatelessWidget { - final Artist artist; - final Widget info; - const StatsArtistItem({ - super.key, - required this.artist, - required this.info, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - title: Text(artist.name!), - horizontalTitleGap: 8, - leading: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - (artist.images).asUrlString( - placeholder: ImagePlaceholder.artist, - ), - ), - ), - trailing: info, - onTap: () { - ServiceUtils.pushNamed( - context, - ArtistPage.name, - pathParameters: {"id": artist.id!}, - ); - }, - ); - } -} diff --git a/lib/components/stats/common/playlist_item.dart b/lib/components/stats/common/playlist_item.dart deleted file mode 100644 index b07311ab..00000000 --- a/lib/components/stats/common/playlist_item.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class StatsPlaylistItem extends StatelessWidget { - final PlaylistSimple playlist; - final Widget info; - const StatsPlaylistItem( - {super.key, required this.playlist, required this.info}); - - @override - Widget build(BuildContext context) { - return ListTile( - horizontalTitleGap: 8, - leading: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: UniversalImage( - path: (playlist.images).asUrlString( - placeholder: ImagePlaceholder.collection, - ), - width: 40, - height: 40, - ), - ), - title: Text(playlist.name!), - subtitle: Text( - playlist.description!.replaceAll(htmlTagRegexp, ''), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - trailing: info, - onTap: () { - ServiceUtils.pushNamed( - context, - PlaylistPage.name, - pathParameters: {"id": playlist.id!}, - extra: playlist, - ); - }, - ); - } -} diff --git a/lib/components/stats/common/track_item.dart b/lib/components/stats/common/track_item.dart deleted file mode 100644 index 6ba6b886..00000000 --- a/lib/components/stats/common/track_item.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/track/track.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class StatsTrackItem extends StatelessWidget { - final Track track; - final Widget info; - const StatsTrackItem({ - super.key, - required this.track, - required this.info, - }); - - @override - Widget build(BuildContext context) { - return ListTile( - horizontalTitleGap: 8, - leading: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: UniversalImage( - path: (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - width: 40, - height: 40, - ), - ), - title: Text(track.name!), - subtitle: ArtistLink( - artists: track.artists!, - mainAxisAlignment: WrapAlignment.start, - ), - trailing: info, - onTap: () { - ServiceUtils.pushNamed( - context, - TrackPage.name, - pathParameters: { - "id": track.id!, - }, - ); - }, - ); - } -} diff --git a/lib/components/stats/summary/summary.dart b/lib/components/stats/summary/summary.dart deleted file mode 100644 index 61f3bd6c..00000000 --- a/lib/components/stats/summary/summary.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/stats/summary/summary_card.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/pages/stats/albums/albums.dart'; -import 'package:spotube/pages/stats/artists/artists.dart'; -import 'package:spotube/pages/stats/fees/fees.dart'; -import 'package:spotube/pages/stats/minutes/minutes.dart'; -import 'package:spotube/pages/stats/playlists/playlists.dart'; -import 'package:spotube/pages/stats/streams/streams.dart'; -import 'package:spotube/provider/history/summary.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class StatsPageSummarySection extends HookConsumerWidget { - const StatsPageSummarySection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final summary = ref.watch(playbackHistorySummaryProvider); - - return SliverPadding( - padding: const EdgeInsets.all(10), - sliver: SliverLayoutBuilder(builder: (context, constrains) { - return SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: constrains.isXs - ? 2 - : constrains.smAndDown - ? 3 - : constrains.mdAndDown - ? 4 - : constrains.lgAndDown - ? 5 - : 6, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: constrains.isXs ? 1.3 : 1.5, - ), - delegate: SliverChildListDelegate([ - SummaryCard( - title: summary.duration.inMinutes.toDouble(), - unit: "minutes", - description: 'Listened to music', - color: Colors.purple, - onTap: () { - ServiceUtils.pushNamed(context, StatsMinutesPage.name); - }, - ), - SummaryCard( - title: summary.tracks.toDouble(), - unit: "songs", - description: 'Streamed overall', - color: Colors.lightBlue, - onTap: () { - ServiceUtils.pushNamed(context, StatsStreamsPage.name); - }, - ), - SummaryCard.unformatted( - title: usdFormatter.format(summary.fees.toDouble()), - unit: "", - description: 'Owed to artists\nthis month', - color: Colors.green, - onTap: () { - ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); - }, - ), - SummaryCard( - title: summary.artists.toDouble(), - unit: "artist's", - description: 'Music reached you', - color: Colors.yellow, - onTap: () { - ServiceUtils.pushNamed(context, StatsArtistsPage.name); - }, - ), - SummaryCard( - title: summary.albums.toDouble(), - unit: "full albums", - description: 'Got your love', - color: Colors.pink, - onTap: () { - ServiceUtils.pushNamed(context, StatsAlbumsPage.name); - }, - ), - SummaryCard( - title: summary.playlists.toDouble(), - unit: "playlists", - description: 'Were on repeat', - color: Colors.teal, - onTap: () { - ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); - }, - ), - ]), - ); - }), - ); - } -} diff --git a/lib/components/stats/summary/summary_card.dart b/lib/components/stats/summary/summary_card.dart deleted file mode 100644 index 243c50e8..00000000 --- a/lib/components/stats/summary/summary_card.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:spotube/collections/formatters.dart'; - -class SummaryCard extends StatelessWidget { - final String title; - final String unit; - final String description; - final VoidCallback? onTap; - - final MaterialColor color; - - SummaryCard({ - super.key, - required double title, - required this.unit, - required this.description, - required this.color, - this.onTap, - }) : title = compactNumberFormatter.format(title); - - const SummaryCard.unformatted({ - super.key, - required this.title, - required this.unit, - required this.description, - required this.color, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final ThemeData(:textTheme, :brightness) = Theme.of(context); - - final descriptionNewLines = description.split("").where((s) => s == "\n"); - - return Card( - color: brightness == Brightness.dark ? color.shade100 : color.shade50, - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 15), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AutoSizeText.rich( - TextSpan( - children: [ - TextSpan( - text: title, - style: textTheme.headlineLarge?.copyWith( - color: color.shade900, - ), - ), - TextSpan( - text: " $unit", - style: textTheme.titleMedium?.copyWith( - color: color.shade900, - ), - ), - ], - ), - maxLines: 1, - ), - const Gap(5), - AutoSizeText( - description, - maxLines: description.contains("\n") - ? descriptionNewLines.length + 1 - : 1, - minFontSize: 9, - style: textTheme.labelMedium!.copyWith( - color: color.shade900, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/components/stats/top/albums.dart b/lib/components/stats/top/albums.dart deleted file mode 100644 index 51bcf5b0..00000000 --- a/lib/components/stats/top/albums.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/stats/common/album_item.dart'; -import 'package:spotube/provider/history/top.dart'; - -class TopAlbums extends HookConsumerWidget { - const TopAlbums({super.key}); - - @override - Widget build(BuildContext context, ref) { - final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final albums = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.albums)); - - return SliverList.builder( - itemCount: albums.length, - itemBuilder: (context, index) { - final album = albums[index]; - return StatsAlbumItem( - album: album.album, - info: Text( - "${compactNumberFormatter.format(album.count)} plays", - ), - ); - }, - ); - } -} diff --git a/lib/components/stats/top/artists.dart b/lib/components/stats/top/artists.dart deleted file mode 100644 index d6d0c98d..00000000 --- a/lib/components/stats/top/artists.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/stats/common/artist_item.dart'; -import 'package:spotube/provider/history/top.dart'; - -class TopArtists extends HookConsumerWidget { - const TopArtists({super.key}); - - @override - Widget build(BuildContext context, ref) { - final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final artists = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.artists)); - - return SliverList.builder( - itemCount: artists.length, - itemBuilder: (context, index) { - final artist = artists[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text("${compactNumberFormatter.format(artist.count)} plays"), - ); - }, - ); - } -} diff --git a/lib/components/stats/top/top.dart b/lib/components/stats/top/top.dart deleted file mode 100644 index df1275e8..00000000 --- a/lib/components/stats/top/top.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/shared/themed_button_tab_bar.dart'; -import 'package:spotube/components/stats/top/albums.dart'; -import 'package:spotube/components/stats/top/artists.dart'; -import 'package:spotube/components/stats/top/tracks.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/history/top.dart'; - -class StatsPageTopSection extends HookConsumerWidget { - const StatsPageTopSection({super.key}); - - @override - Widget build(BuildContext context, ref) { - final tabController = useTabController(initialLength: 3); - final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final historyDurationNotifier = - ref.watch(playbackHistoryTopDurationProvider.notifier); - - return SliverMainAxisGroup( - slivers: [ - SliverAppBar( - floating: true, - flexibleSpace: ThemedButtonsTabBar( - controller: tabController, - tabs: const [ - Tab( - child: Padding( - padding: EdgeInsets.all(5), - child: Text("Top Tracks"), - ), - ), - Tab( - child: Padding( - padding: EdgeInsets.all(5), - child: Text("Top Artists"), - ), - ), - Tab( - child: Padding( - padding: EdgeInsets.all(5), - child: Text("Top Albums"), - ), - ), - ], - ), - ), - SliverToBoxAdapter( - child: Align( - alignment: Alignment.centerRight, - child: DropdownButton( - style: Theme.of(context).textTheme.bodySmall!, - isDense: true, - padding: const EdgeInsets.all(4), - borderRadius: BorderRadius.circular(4), - underline: const SizedBox(), - value: historyDuration, - onChanged: (value) { - if (value == null) return; - historyDurationNotifier.update((_) => value); - }, - icon: const Icon(Icons.arrow_drop_down), - items: const [ - DropdownMenuItem( - value: HistoryDuration.days7, - child: Text("This week"), - ), - DropdownMenuItem( - value: HistoryDuration.days30, - child: Text("This month"), - ), - DropdownMenuItem( - value: HistoryDuration.months6, - child: Text("Last 6 months"), - ), - DropdownMenuItem( - value: HistoryDuration.year, - child: Text("This year"), - ), - DropdownMenuItem( - value: HistoryDuration.years2, - child: Text("Last 2 years"), - ), - DropdownMenuItem( - value: HistoryDuration.allTime, - child: Text("All time"), - ), - ], - ), - ), - ), - ListenableBuilder( - listenable: tabController, - builder: (context, _) { - return switch (tabController.index) { - 1 => const TopArtists(), - 2 => const TopAlbums(), - _ => const TopTracks(), - }; - }, - ), - ], - ); - } -} diff --git a/lib/components/stats/top/tracks.dart b/lib/components/stats/top/tracks.dart deleted file mode 100644 index bffa4ecd..00000000 --- a/lib/components/stats/top/tracks.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/stats/common/track_item.dart'; -import 'package:spotube/provider/history/top.dart'; - -class TopTracks extends HookConsumerWidget { - const TopTracks({super.key}); - - @override - Widget build(BuildContext context, ref) { - final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final tracks = ref.watch( - playbackHistoryTopProvider(historyDuration) - .select((value) => value.tracks), - ); - - return SliverList.builder( - itemCount: tracks.length, - itemBuilder: (context, index) { - final track = tracks[index]; - return StatsTrackItem( - track: track.track, - info: Text( - "${compactNumberFormatter.format(track.count)} plays", - ), - ); - }, - ); - } -} diff --git a/lib/extensions/album_simple.dart b/lib/extensions/album_simple.dart index 5678390c..7c8ae09e 100644 --- a/lib/extensions/album_simple.dart +++ b/lib/extensions/album_simple.dart @@ -1,6 +1,21 @@ import 'package:spotify/spotify.dart'; extension AlbumExtensions on AlbumSimple { + Map toJson() { + return { + "albumType": albumType?.name, + "id": id, + "name": name, + "images": images + ?.map((image) => { + "height": image.height, + "url": image.url, + "width": image.width, + }) + .toList(), + }; + } + Album toAlbum() { Album album = Album(); album.albumType = albumType; diff --git a/lib/extensions/artist_simple.dart b/lib/extensions/artist_simple.dart index 7997355d..6a80300e 100644 --- a/lib/extensions/artist_simple.dart +++ b/lib/extensions/artist_simple.dart @@ -1,5 +1,17 @@ import 'package:spotify/spotify.dart'; +extension ArtistJson on ArtistSimple { + Map toJson() { + return { + "href": href, + "id": id, + "name": name, + "type": type, + "uri": uri, + }; + } +} + extension ArtistExtension on List { String asString() { return map((e) => e.name?.replaceAll(",", " ")).join(", "); diff --git a/lib/extensions/track.dart b/lib/extensions/track.dart index 02c0c492..9755179d 100644 --- a/lib/extensions/track.dart +++ b/lib/extensions/track.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/album_simple.dart'; +import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; extension TrackExtensions on Track { @@ -37,6 +39,33 @@ extension TrackExtensions on Track { return this; } + + Map toJson() { + return TrackExtensions.trackToJson(this); + } + + static Map trackToJson(Track track) { + return { + "album": track.album?.toJson(), + "artists": track.artists?.map((artist) => artist.toJson()).toList(), + "available_markets": track.availableMarkets?.map((e) => e.name).toList(), + "disc_number": track.discNumber, + "duration_ms": track.durationMs, + "explicit": track.explicit, + // "external_ids"track.: externalIds, + // "external_urls"track.: externalUrls, + "href": track.href, + "id": track.id, + "is_playable": track.isPlayable, + // "linked_from"track.: linkedFrom, + "name": track.name, + "popularity": track.popularity, + "preview_rrl": track.previewUrl, + "track_number": track.trackNumber, + "type": track.type, + "uri": track.uri, + }; + } } extension TrackSimpleExtensions on TrackSimple { diff --git a/lib/hooks/configurators/use_close_behavior.dart b/lib/hooks/configurators/use_close_behavior.dart index 3df6a528..79b14fa9 100644 --- a/lib/hooks/configurators/use_close_behavior.dart +++ b/lib/hooks/configurators/use_close_behavior.dart @@ -1,31 +1,29 @@ 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'; +// ignore: depend_on_referenced_packages import 'package:local_notifier/local_notifier.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; -final closeNotification = !kIsDesktop - ? null - : (LocalNotification( - title: 'Spotube', - body: 'Running in background. Minimized to System Tray', - actions: [ - LocalNotificationAction(text: 'Close The App'), - ], - )..onClickAction = (value) { - exit(0); - }); +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 windowManager.hide(); + await DesktopTools.window.hide(); closeNotification?.show(); } else { exit(0); diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart index 90d062dc..2650b05c 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -7,7 +7,7 @@ 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:spotube/utils/platform.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; final appLinks = AppLinks(); final linkStream = appLinks.allStringLinkStream.asBroadcastStream(); @@ -53,7 +53,7 @@ void useDeepLinking(WidgetRef ref) { StreamSubscription? mediaStream; - if (kIsMobile) { + if (DesktopTools.platform.isMobile) { FlutterSharingIntent.instance.getInitialSharing().then(uriListener); mediaStream = diff --git a/lib/hooks/configurators/use_disable_battery_optimizations.dart b/lib/hooks/configurators/use_disable_battery_optimizations.dart index 4aa51b74..a9afef45 100644 --- a/lib/hooks/configurators/use_disable_battery_optimizations.dart +++ b/lib/hooks/configurators/use_disable_battery_optimizations.dart @@ -1,12 +1,12 @@ import 'package:disable_battery_optimization/disable_battery_optimization.dart'; - +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/utils/platform.dart'; void useDisableBatteryOptimizations() { useAsyncEffect(() async { - if (!kIsAndroid || KVStoreService.askedForBatteryOptimization) return; + if (!DesktopTools.platform.isAndroid || + KVStoreService.askedForBatteryOptimization) return; await DisableBatteryOptimization.showDisableBatteryOptimizationSettings(); diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart index 9cccbfe0..86b495c4 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -1,18 +1,17 @@ import 'package:device_info_plus/device_info_plus.dart'; - +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/hooks/utils/use_async_effect.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/utils/platform.dart'; void useGetStoragePermissions(WidgetRef ref) { - final context = useContext(); + final isMounted = useIsMounted(); useAsyncEffect( () async { - if (!kIsMobile) return; + if (!DesktopTools.platform.isMobile) return; final androidInfo = await DeviceInfoPlugin().androidInfo; @@ -26,11 +25,11 @@ void useGetStoragePermissions(WidgetRef ref) { if (hasNoStoragePerm) { await Permission.storage.request(); - if (context.mounted) ref.invalidate(localTracksProvider); + if (isMounted()) ref.invalidate(localTracksProvider); } if (hasNoAudioPerm) { await Permission.audio.request(); - if (context.mounted) ref.invalidate(localTracksProvider); + if (isMounted()) ref.invalidate(localTracksProvider); } }, null, diff --git a/lib/hooks/configurators/use_init_sys_tray.dart b/lib/hooks/configurators/use_init_sys_tray.dart new file mode 100644 index 00000000..0bce6727 --- /dev/null +++ b/lib/hooks/configurators/use_init_sys_tray.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +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/collections/intents.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; + +void useInitSysTray(WidgetRef ref) { + final context = useContext(); + final systemTray = useRef(null); + + final initializeMenu = useCallback(() async { + systemTray.value?.destroy(); + final playlist = ref.read(proxyPlaylistProvider); + final playlistQueue = ref.read(proxyPlaylistProvider.notifier); + final preferences = ref.read(userPreferencesProvider); + if (!preferences.showSystemTrayIcon) { + await systemTray.value?.destroy(); + systemTray.value = null; + return; + } + final enabled = !playlist.isFetching; + systemTray.value = await DesktopTools.createSystemTrayMenu( + title: DesktopTools.platform.isWindows ? "Spotube" : "", + iconPath: "assets/spotube-logo.png", + windowsIconPath: "assets/spotube-logo.ico", + items: [ + MenuItemLabel( + label: "Show/Hide", + name: "show-hide", + onClicked: (item) async { + if (await DesktopTools.window.isVisible()) { + await DesktopTools.window.hide(); + } else { + await DesktopTools.window.show(); + } + }, + ), + MenuSeparator(), + MenuItemLabel( + label: "Play/Pause", + name: "play-pause", + enabled: enabled, + onClicked: (_) async { + Actions.maybeInvoke( + context, PlayPauseIntent(ref)) ?? + PlayPauseAction().invoke(PlayPauseIntent(ref)); + }, + ), + MenuItemLabel( + label: "Next", + name: "next", + enabled: enabled && (playlist.tracks.length) > 1, + onClicked: (p0) async { + await playlistQueue.next(); + }, + ), + MenuItemLabel( + label: "Previous", + name: "previous", + enabled: enabled && (playlist.tracks.length) > 1, + onClicked: (p0) async { + await playlistQueue.previous(); + }, + ), + MenuSeparator(), + MenuItemLabel( + label: "Quit", + name: "quit", + onClicked: (item) async { + exit(0); + }, + ), + ], + onEvent: (event, tray) async { + if (DesktopTools.platform.isWindows) { + switch (event) { + case SystemTrayEvent.click: + await DesktopTools.window.show(); + break; + case SystemTrayEvent.rightClick: + await tray.popUpContextMenu(); + break; + default: + } + } else { + switch (event) { + case SystemTrayEvent.rightClick: + await DesktopTools.window.show(); + break; + case SystemTrayEvent.click: + await tray.popUpContextMenu(); + break; + default: + } + } + }, + ); + }, [ref]); + + useReassemble(initializeMenu); + + ref.listen( + proxyPlaylistProvider, + (previous, next) { + initializeMenu(); + }, + ); + ref.listen( + userPreferencesProvider.select((s) => s.showSystemTrayIcon), + (previous, next) { + initializeMenu(); + }, + ); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + initializeMenu(); + }); + return () async { + await systemTray.value?.destroy(); + }; + }, [initializeMenu]); +} diff --git a/lib/hooks/configurators/use_update_checker.dart b/lib/hooks/configurators/use_update_checker.dart new file mode 100644 index 00000000..1a6a5be5 --- /dev/null +++ b/lib/hooks/configurators/use_update_checker.dart @@ -0,0 +1,100 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:spotube/collections/env.dart'; + +import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/hooks/controllers/use_package_info.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:version/version.dart'; + +void useUpdateChecker(WidgetRef ref) { + final isCheckUpdateEnabled = + ref.watch(userPreferencesProvider.select((s) => s.checkUpdate)); + final packageInfo = usePackageInfo( + appName: 'Spotube', + packageName: 'spotube', + ); + final Future> Function() checkUpdate = useCallback( + () async { + final value = await http.get( + Uri.parse( + "https://api.github.com/repos/KRTirtho/spotube/releases/latest"), + ); + final tagName = + (jsonDecode(value.body)["tag_name"] as String).replaceAll("v", ""); + final currentVersion = packageInfo.version == "Unknown" + ? null + : Version.parse(packageInfo.version); + final latestVersion = + tagName == "nightly" ? null : Version.parse(tagName); + return [currentVersion, latestVersion]; + }, + [packageInfo.version], + ); + + final context = useContext(); + + download(String url) => launchUrlString( + url, + mode: LaunchMode.externalApplication, + ); + + useEffect(() { + if (!Env.enableUpdateChecker) return; + if (!isCheckUpdateEnabled) return null; + checkUpdate().then((value) { + final currentVersion = value.first; + final latestVersion = value.last; + if (currentVersion == null || + latestVersion == null || + (latestVersion.isPreRelease && !currentVersion.isPreRelease) || + (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return; + if (latestVersion <= currentVersion) return; + showDialog( + context: context, + barrierDismissible: true, + barrierColor: Colors.black26, + builder: (context) { + const url = + "https://spotube.krtirtho.dev/other-downloads/stable-downloads"; + return AlertDialog( + title: const Text("Spotube has an update"), + actions: [ + FilledButton( + child: const Text("Download Now"), + onPressed: () => download(url), + ), + ], + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("Spotube v${value.last} has been released"), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Read the latest "), + AnchorButton( + "release notes", + style: const TextStyle(color: Colors.blue), + onTap: () => launchUrlString( + url, + mode: LaunchMode.externalApplication, + ), + ), + ], + ), + ], + ), + ); + }, + ); + }); + return null; + }, [packageInfo, isCheckUpdateEnabled]); +} diff --git a/lib/hooks/configurators/use_window_listener.dart b/lib/hooks/configurators/use_window_listener.dart index 5977ea8e..b91ad413 100644 --- a/lib/hooks/configurators/use_window_listener.dart +++ b/lib/hooks/configurators/use_window_listener.dart @@ -1,8 +1,6 @@ import 'package:flutter/widgets.dart'; - +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; class CallbackWindowListener implements WindowListener { final VoidCallback? _onWindowClose; @@ -156,8 +154,6 @@ void useWindowListener({ VoidCallback? onWindowEvent, }) { useEffect(() { - if (!kIsDesktop) return null; - final listener = CallbackWindowListener( onWindowClose: onWindowClose, onWindowFocus: onWindowFocus, @@ -176,9 +172,9 @@ void useWindowListener({ onWindowUndocked: onWindowUndocked, onWindowEvent: onWindowEvent, ); - windowManager.addListener(listener); + DesktopTools.window.addListener(listener); return () { - windowManager.removeListener(listener); + DesktopTools.window.removeListener(listener); }; }, [ onWindowClose, diff --git a/lib/hooks/utils/use_palette_color.dart b/lib/hooks/utils/use_palette_color.dart index e6d8b398..9269edd7 100644 --- a/lib/hooks/utils/use_palette_color.dart +++ b/lib/hooks/utils/use_palette_color.dart @@ -14,6 +14,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { final context = useContext(); final theme = Theme.of(context); final paletteColor = ref.watch(_paletteColorState); + final mounted = useIsMounted(); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { @@ -24,7 +25,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { width: 50, ), ); - if (!context.mounted) return; + if (!mounted()) return; final color = theme.brightness == Brightness.light ? palette.lightMutedColor ?? palette.lightVibrantColor : palette.darkMutedColor ?? palette.darkVibrantColor; @@ -40,7 +41,7 @@ PaletteColor usePaletteColor(String imageUrl, WidgetRef ref) { PaletteGenerator usePaletteGenerator(String imageUrl) { final palette = useState(PaletteGenerator.fromColors([])); - final context = useContext(); + final mounted = useIsMounted(); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { @@ -51,7 +52,7 @@ PaletteGenerator usePaletteGenerator(String imageUrl) { width: 50, ), ); - if (!context.mounted) return; + if (!mounted()) return; palette.value = newPalette; }); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 04fc8566..832862c0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -107,9 +107,6 @@ "always_on_top": "Always on top", "exit_mini_player": "Exit Mini player", "download_location": "Download location", - "local_library": "Local library", - "add_library_location": "Add to library", - "remove_library_location": "Remove from library", "account": "Account", "login_with_spotify": "Login with your Spotify account", "connect_with_spotify": "Connect with Spotify", @@ -298,7 +295,6 @@ "delete_playlist": "Delete Playlist", "delete_playlist_confirmation": "Are you sure you want to delete this playlist?", "local_tracks": "Local Tracks", - "local_tab": "Local", "song_link": "Song Link", "skip_this_nonsense": "Skip this nonsense", "freedom_of_music": "“Freedom of Music”", @@ -324,6 +320,5 @@ "select": "Select", "connect_client_alert": "You're being controlled by {client}", "this_device": "This Device", - "remote": "Remote", - "stats": "Stats" + "remote": "Remote" } \ No newline at end of file diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb deleted file mode 100644 index fb00a925..00000000 --- a/lib/l10n/app_eu.arb +++ /dev/null @@ -1,329 +0,0 @@ -{ - "guest": "Gonbidatua", - "browse": "Arakatu", - "search": "Bilatu", - "library": "Liburutegia", - "lyrics": "Hitzak", - "settings": "Ezarpenak", - "genre_categories_filter": "Kategoria edo generoak filtratu...", - "genre": "Generoa", - "personalized": "Pertsonalizatua", - "featured": "Nabarmenduak", - "new_releases": "Argitaratze berriak", - "songs": "Abestiak", - "playing_track": "{track} erreproduzitzen", - "queue_clear_alert": "Uneko zerrenda ezabatuko da. {track_length} abesti ezabatuko dira.\nJarraitu nahi duzu?", - "load_more": "Gehiago kargatu", - "playlists": "Zerrendak", - "artists": "Artistak", - "albums": "Albumak", - "tracks": "Kantak", - "downloads": "Deskargak", - "filter_playlists": "Zure zerrendak filtratu...", - "liked_tracks": "Gustuko Kantak", - "liked_tracks_description": "Zure gustuko kanta guztiak", - "create_playlist": "Sortu zerrenda", - "create_a_playlist": "Sortu zerrenda bat", - "update_playlist": "Eguneratu zerrenda", - "create": "Sortu", - "cancel": "Ezeztatu", - "update": "Eguneratu", - "playlist_name": "Zerrenda Izena", - "name_of_playlist": "Zerrendaren izena", - "description": "Deskribapena", - "public": "Publikoa", - "collaborative": "Kolaboratiboa", - "search_local_tracks": "Bilatu kanta lokalak...", - "play": "Erreproduzitu", - "delete": "Ezabatu", - "none": "Batere ez", - "sort_a_z": "Ordenatu A-Z", - "sort_z_a": "Ordenatu Z-A", - "sort_artist": "Ordenatu Artistaren arabera", - "sort_album": "Ordenatu Albumaren arabera", - "sort_duration": "Ordenar Iraupenaren arabera", - "sort_tracks": "Ordenatu Kantak", - "currently_downloading": "Oraintxe ({tracks_length}) deskargatzen", - "cancel_all": "Ezeztatu dena", - "filter_artist": "Filtratu artistak...", - "followers": "{followers} Jarraitzaile", - "add_artist_to_blacklist": "Gehitu artista zerrenda beltzera", - "top_tracks": "Top Kantak", - "fans_also_like": "Fan-ek hau ere gustuko dute", - "loading": "Kargatzen...", - "artist": "Artista", - "blacklisted": "Zerrenda beltzean", - "following": "Jarraitzen", - "follow": "Jarraitu", - "artist_url_copied": "Artistaren URL-a arbelera kopiatua", - "added_to_queue": "{tracks} kanta zerrendara gehituak", - "filter_albums": "Albumak filtratu...", - "synced": "Sinkronizatuta", - "plain": "Arrunta", - "shuffle": "Ausaz", - "search_tracks": "Bilatu kantak...", - "released": "Argitaratua", - "error": "Errorea: {error}", - "title": "Izenburua", - "time": "Iraupena", - "more_actions": "Ekintza gehiago", - "download_count": "({count}) deskarga", - "add_count_to_playlist": "Gehitu ({count}) zerrendara", - "add_count_to_queue": "Gehitu ({count}) ilarara", - "play_count_next": "Erreproduzitu hurrengo ({count})-ak", - "album": "Albuma", - "copied_to_clipboard": "{data} arbelean kopiatua", - "add_to_following_playlists": "Gehitu {track} hurrengo erreprodukzio-zerrendetara", - "add": "Gehitu", - "added_track_to_queue": "{track} zerrendan gehitua", - "add_to_queue": "Gehitu zerrendan", - "track_will_play_next": "{track} erreproduzituko da ondoren", - "play_next": "Hurrengo erreprodukzioa", - "removed_track_from_queue": "{track} zerrendatik ezabatua", - "remove_from_queue": "Ezabatu ilaratik", - "remove_from_favorites": "Ezabatu gogokoetatik", - "save_as_favorite": "Gorde gogokoetan", - "add_to_playlist": "Gehitu zerrendara", - "remove_from_playlist": "Ezabatu zerrendatik", - "add_to_blacklist": "Gehitu zerrenda beltzera", - "remove_from_blacklist": "Ezabatu zerrenda beltzetik", - "share": "Elkarbanatu", - "mini_player": "Mini Erreproduzitzailea", - "slide_to_seek": "Arrastatu aurrerantz edo atzearantz bilatzeko", - "shuffle_playlist": "Erreproduzitu zerrenda ausazko ordenean", - "unshuffle_playlist": "Desgaitu ausazko erreprodukzioa", - "previous_track": "Aurreko pista", - "next_track": "Hurrengo pista", - "pause_playback": "Pausatu erreprodukzioa", - "resume_playback": "Berrabiarazi erreprodukzioa", - "loop_track": "Kanta begiztan", - "repeat_playlist": "Errepikatu lista", - "queue": "Ilara", - "alternative_track_sources": "Kanten iturri alternatiboak", - "download_track": "Deskargatu kanta", - "tracks_in_queue": "{tracks} kanta zerrendan", - "clear_all": "Garbitu dena", - "show_hide_ui_on_hover": "Erakutsi/Ezkutatu interfazea kurtsorea pasatzean", - "always_on_top": "Beti ikusgai", - "exit_mini_player": "Irten mini erreproduzitzailetik", - "download_location": "Deskargen kokapena", - "account": "Kontua", - "login_with_spotify": "Hasi saioa zure Spotify kontuarekin", - "connect_with_spotify": "Spotify-rekin konektatu", - "logout": "Itxi saioa", - "logout_of_this_account": "Itxi kontu honen saioa", - "language_region": "Hizkuntza eta Herrialdea", - "language": "Hizkuntza", - "system_default": "Sisteman lehenetsia", - "market_place_region": "Dendaren herrialdea", - "recommendation_country": "Gomendio herrialdea", - "appearance": "Itxura", - "layout_mode": "Diseinu modua", - "override_layout_settings": "Responsive diseinu moduaren ezarpenak ezeztatu", - "adaptive": "Moldagarria", - "compact": "Trinkoa", - "extended": "Hedatua", - "theme": "Gaia", - "dark": "Iluna", - "light": "Argia", - "system": "Sistema", - "accent_color": "Azentu kolorea", - "sync_album_color": "Sinkronizatu albumaren kolorea", - "sync_album_color_description": "Albumaren artearen kolore nagusia erabili azentu kolore bezala", - "playback": "Erreprodukzioa", - "audio_quality": "Audioaren kalitatea", - "high": "Altua", - "low": "Baxua", - "pre_download_play": "Aurre-deskargatu eta erreproduzitu", - "pre_download_play_description": "Streaming egin beharrean, byte-ak deskargatu eta erreproduzitu (banda-zabalera handia duten erabiltzaileentzat gomendagarria)", - "skip_non_music": "Musika ez diren segmentuak baztertu (SponsorBlock)", - "blacklist_description": "Zerrenda beltzeko abesti eta artistak", - "wait_for_download_to_finish": "Mesedez, itxaron uneko deskarga bukatu arte", - "desktop": "Mahaigaina", - "close_behavior": "Ixterako Portaera", - "close": "Itxi", - "minimize_to_tray": "Sistemako erretilura minimizatu", - "show_tray_icon": "Erakutsi ikonoa sistemaren erretiluan", - "about": "Honi buruz", - "u_love_spotube": "Badakigu Spotube maite duzula", - "check_for_updates": "Bilatu eguneraketak", - "about_spotube": "Spotube-ri buruz", - "blacklist": "Zerrenda beltza", - "please_sponsor": "Mesedez, babestu/diruz lagundu", - "spotube_description": "Spotube, arina, plataforma-anitza eta doakoa den Spotify-ren bezeroa", - "version": "Bertsioa", - "build_number": "Konpilazio zenbakia", - "founder": "Sortzailea", - "repository": "Errepositorioa", - "bug_issues": "Erroreak eta arazoak", - "made_with": "Bangladesh🇧🇩-en ❤️-z egina", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Lizentzia", - "add_spotify_credentials": "Gehitu zure Spotify kredentzialak hasi ahal izateko", - "credentials_will_not_be_shared_disclaimer": "Ez arduratu, zure kredentzialak ez ditugu bilduko edo inorekin elkarbanatuko", - "know_how_to_login": "Ez dakizu nola egin?", - "follow_step_by_step_guide": "Jarraitu pausoz-pausoko gida", - "spotify_cookie": "Spotify-ren {name} cookiea", - "cookie_name_cookie": "{name} cookiea", - "fill_in_all_fields": "Mesedez, osatu eremu guztiak", - "submit": "Bidali", - "exit": "Irten", - "previous": "Aurrekoa", - "next": "Hurrengoa", - "done": "Eginda", - "step_1": "1. pausua", - "first_go_to": "Hasteko, joan hona", - "login_if_not_logged_in": "eta hasi saioa/sortu kontua lehendik ez baduzu eginda", - "step_2": "2. pausua", - "step_2_steps": "1. Saioa hasita duzularik, sakatu F12 edo saguaren eskuineko botoia klikatu > Ikuskatu nabigatzaileko garapen tresnak irekitzeko.\n2. Joan \"Aplikazio\" (Chrome, Edge, Brave, etab.) edo \"Biltegiratzea\" (Firefox, Palemoon, etab.)\n3. Joan \"Cookieak\" atalera eta gero \"https://accounts.spotify.com\" azpiatalera", - "step_3": "3. pausua", - "step_3_steps": "Kopiatu \"sp_dc\" cookiearen balioa", - "success_emoji": "Eginda! 🥳", - "success_message": "Ongi hasi duzu zure Spotify kontua. Lan bikaina, lagun!", - "step_4": "4. pausua", - "step_4_steps": "Itsatsi \"sp_dc\"-tik kopiatutako balioa", - "something_went_wrong": "Zerbaitek huts egin du", - "piped_instance": "Piped zerbitzariaren instantzia", - "piped_description": "Kanten koizidentzietan erabiltzeko Piped zerbitzariaren instantzia", - "piped_warning": "Batzuk agian ez dute ongi funtzionatuko, zure ardurapean erabili", - "generate_playlist": "Sortu Zerrenda", - "track_exists": "{track} kanta dagoeneko badago", - "replace_downloaded_tracks": "Ordezkatu deskargatutako kanta guztiak", - "skip_download_tracks": "Deskargatutako kanta guztien deskarga baztertu", - "do_you_want_to_replace": "Dagoen kanta ordezkatu nahi duzu??", - "replace": "Ordezkatu", - "skip": "Baztertu", - "select_up_to_count_type": "Aukertu {count} {type}", - "select_genres": "Aukeratu Generoak", - "add_genres": "Gehitu Generoak", - "country": "Herrialdea", - "number_of_tracks_generate": "Sortzeko kanta kopurua", - "acousticness": "Akustikotasuna", - "danceability": "Dantzagarritasuna", - "energy": "Energia", - "instrumentalness": "Instrumentaltasuna", - "liveness": "Zuzenean", - "loudness": "Ozentasuna", - "speechiness": "Hitzaldia", - "valence": "Balentzia", - "popularity": "Populartasuna", - "key": "Tonua", - "duration": "Iraupena (s)", - "tempo": "Tenpoa (BPM)", - "mode": "Modua", - "time_signature": "Konpasa", - "short": "Motza", - "medium": "Ertaina", - "long": "Luzea", - "min": "Min.", - "max": "Max.", - "target": "Helburua", - "moderate": "Moderatua", - "deselect_all": "Desaukeratu dena", - "select_all": "Aukeratu dena", - "are_you_sure": "Ziur zaude?", - "generating_playlist": "Zure pertsonalizatutako zerrenda sortzen...", - "selected_count_tracks": "{count} kanta aukeratuta", - "download_warning": "Abesti guztiak aldi berean deskargatuz gero, argi dago musika pirateatzen ari zarela eta musikaren gizarte sortzaileari kalte egiten diozula. Honen jakitun izan eta artisten lan gogorra errespetatu eta babestea espero dut", - "download_ip_ban_warning": "Bidenabar, baliteke zure IPa YouTuben blokeatzea deskarga eskera gehiegi egiten badituzu. IPa blokeatzeak esan nahi du ezin izango duzula YouTube erabili (nahiz eta saioa hasia izan) gutxienez 2-3 hilabetez IP helbide horretatik. Eta Spotube ez da erantzule izango hori gertatzen bazaizu", - "by_clicking_accept_terms": "'Onartu' klikatzean, ondorengo baldintzak onartzen dituzu:", - "download_agreement_1": "Badakit musika pirateatzen ari naizela. Gaiztoa naiz", - "download_agreement_2": "Ahal dudanean lagunduko diot artistari baina oraingoz ez dut bere artea erosteko dirurik", - "download_agreement_3": "Erabat jakitun naiz YouTubek nire IPa blokea dezakeela eta ez diot Spotube-ri edo bere jabe/laguntzaileei erantzukizunik eskatuko nire oraingo jokaerak ekar ditzakeen arazoengatik", - "decline": "Baztertu", - "accept": "Onartu", - "details": "Xehetasunak", - "youtube": "YouTube", - "channel": "Kanala", - "likes": "Gustukoak", - "dislikes": "Ez gustukoak", - "views": "Ikuspenak", - "streamUrl": "Streaming-aren URLa", - "stop": "Gelditu", - "sort_newest": "Ordenatu gehitu berrienetik", - "sort_oldest": "Ordenatu gehitu zaharrenetik", - "sleep_timer": "Itzaltzeko tenporizadorea", - "mins": "{minutes} minutu", - "hours": "{hours} ordu", - "hour": "{hours} ordu", - "custom_hours": "Ordu pertsonalizatuak", - "logs": "Log-ak", - "developers": "Garatzaileak", - "not_logged_in": "Ez duzu saioa hasi", - "search_mode": "Bilaketa modua", - "audio_source": "Audio Iturria", - "ok": "OK", - "failed_to_encrypt": "Errorea zifratzean", - "encryption_failed_warning": "Spotube-ek zifratzea darabil datuak modu seguruan biltegiratzeko. Baina huts egin du. Hori dela eta, biltegiratzea ez da segurua izango\nLinux erabiltzen ari bazara, ziurtatu edozein sekretu-zerbitzu (gnome-keyring, kde-wallet, keepassxc etab.) instalatuta duzula", - "querying_info": "Informazioa egiaztatzen...", - "piped_api_down": "Piped-en APIa ez dago eskuragarri", - "piped_down_error_instructions": "Piped-en {pipedInstance} instantzia ez dago martxan une honetan\n\nAldatu instantzia edo aldatu 'API mota' YouTuberen API ofizialera\n\nZiurtatu aplikazioa berrabiarazten duzula aldaketa eta gero", - "you_are_offline": "Une honetan konexiorik gabe zaude", - "connection_restored": "Internet konexioa berrezarri egin da", - "use_system_title_bar": "Erabili sistemako izenburu barra", - "crunching_results": "Emaitzak prozesatzen...", - "search_to_get_results": "Bilatu emaitzak lortzeko", - "use_amoled_mode": "Erabili AMOLED modua", - "pitch_dark_theme": "Dart-en gai iluna", - "normalize_audio": "Normalizatu audioa", - "change_cover": "Aldatu azala", - "add_cover": "Gehitu azala", - "restore_defaults": "Berrezarri berezko balioak", - "download_music_codec": "Deskargatutako musikaren codec-a", - "streaming_music_codec": "Streaming musikaren codec-a", - "login_with_lastfm": "Hasi saioa Last.fm-n", - "connect": "Konektatu", - "disconnect_lastfm": "Deskonektatu Last.fm-tik", - "disconnect": "Deskonektatu", - "username": "Erabiltzaile izena", - "password": "Pasahitza", - "login": "Hasi saioa", - "login_with_your_lastfm": "Hasi saioa Last.fm-ko zure kontuarekin", - "scrobble_to_lastfm": "Scrobble Last.fm-ra", - "go_to_album": "Albumera joan", - "discord_rich_presence": "Discord-en presentzia aberatsa", - "browse_all": "Esploratu dena", - "genres": "Generoak", - "explore_genres": "Esploratu generoak", - "friends": "Lagunak", - "no_lyrics_available": "Sentitzen dut, ezin dira kanta honen hitzak aurkitu", - "start_a_radio": "Hasi Irrati bat", - "how_to_start_radio": "Nola hasi nahi duzu irratia?", - "replace_queue_question": "Uneko zerrenda ordezkatu nahi duzu edo bertan gehitu?", - "endless_playback": "Amaigabeko erreprodukzioa", - "delete_playlist": "Ezabatu zerrenda", - "delete_playlist_confirmation": "Ziur zaude zerrenda ezabatu nahi duzula?", - "local_tracks": "Kanta lokalak", - "song_link": "Kantaren lotura", - "skip_this_nonsense": "Utzi txorakeria hau", - "freedom_of_music": "“Musika Askatasuna”", - "freedom_of_music_palm": "“Musika Askatasuna zure eskuetan”", - "get_started": "Has gaitezen", - "youtube_source_description": "Gomendatua eta hobekien dabilena.", - "piped_source_description": "Aske zara? YouTube bezala, baino askeago.", - "jiosaavn_source_description": "Asia hegoaldeko herrialdeetarako hoberena.", - "highest_quality": "Kalitate Onena: {quality}", - "select_audio_source": "Aukeratu Audio Iturria", - "endless_playback_description": "Gehitu automatikoki kanta berriak\n ilararen bukaeran", - "choose_your_region": "Aukeratu zure herrialdea", - "choose_your_region_description": "Honekin Spotube-k zure kokalerakuari dagokion edukia\neskeiniko dizu.", - "choose_your_language": "Aukeratu zure hizkuntza", - "help_project_grow": "Lagundu proiektu honi hazten", - "help_project_grow_description": "Spotube kode irekiko proiektu bat da. Proiektu hau hazten lagundu dezakezu, erroreak jakinaraziz edo ezaugarri berriak proposatuz.", - "contribute_on_github": "GitHub-en lagundu", - "donate_on_open_collective": "Open Collective-en diruz lagundu", - "browse_anonymously": "Nabigatu Anonimoki", - "enable_connect": "Gaitu konexioa", - "enable_connect_description": "Kontrolatu Spotube beste gailu batzuetatik", - "devices": "Gailuak", - "select": "Aukeratu", - "connect_client_alert": "{client} gailuak kontrolatzen zaitu", - "this_device": "Gailu hau", - "remote": "Urrunekoa", - "local_library": "Liburutegi lokala", - "add_library_location": "Gehitu liburutegira", - "remove_library_location": "Kendu liburutegitik", - "local_tab": "Tokiko", - "stats": "Estatistikak" -} \ No newline at end of file diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb deleted file mode 100644 index d0767e95..00000000 --- a/lib/l10n/app_fi.arb +++ /dev/null @@ -1,329 +0,0 @@ -{ - "guest": "Vieras", - "browse": "Selaa", - "search": "Hae", - "library": "Kirjasto", - "lyrics": "Lyriikat", - "settings": "Asetukset", - "genre_categories_filter": "Suodata kategorioita tai genrejä", - "genre": "Genre", - "personalized": "Personoidut", - "featured": "Esittelyssä", - "new_releases": "Uusi julkaisu", - "songs": "Laulut", - "playing_track": "Soitetaan {track}", - "queue_clear_alert": "Tämä tulee tyhjentämään jonon. {track_length} Kappaleita poistetaan\nHaluatko jatkaa?", - "load_more": "Lataa lisää", - "playlists": "Soittolistat", - "artists": "Artistit", - "albums": "Albumit", - "tracks": "Kappaleet", - "downloads": "Lataukset", - "filter_playlists": "Suodata soittolistasi...", - "liked_tracks": "Tykätyt kappaleet", - "liked_tracks_description": "Kaikki tykättysi kappaleet", - "create_playlist": "Luo soittolista", - "create_a_playlist": "Luo soittolista", - "update_playlist": "Päivitä soittolista", - "create": "Luo", - "cancel": "Peruuta", - "update": "Päivitä", - "playlist_name": "Soittolistan nimi", - "name_of_playlist": "Soittolistan nimi", - "description": "Kuvaus", - "public": "Julkinen", - "collaborative": "Collaborative", - "search_local_tracks": "Hae paikallisia lauluja...", - "play": "Soita", - "delete": "Poista", - "none": "Ei mitään", - "sort_a_z": "Suodata A-Z", - "sort_z_a": "Suodata Z-A", - "sort_artist": "Suodata Artistilta", - "sort_album": "Suodata Albumilta", - "sort_duration": "Suodata Pituudelta", - "sort_tracks": "Suodata Kappaleet", - "currently_downloading": "Ladataan ({tracks_length})", - "cancel_all": "Peru kaikki", - "filter_artist": "Suodata artistit...", - "followers": "{followers} Seuraajaa", - "add_artist_to_blacklist": "Lisää artisti mustalle listalle", - "top_tracks": "Suosituimmat kappaleet", - "fans_also_like": "Fanit myös tykkäsivät", - "loading": "Ladataan...", - "artist": "Artisti", - "blacklisted": "Mustalistattu", - "following": "Seurataan", - "follow": "Seuraa", - "artist_url_copied": "Aristin URL kopioitiin leikepöytään", - "added_to_queue": "Lisättiin {tracks} kappaletta jonoon", - "filter_albums": "Suodata albumit...", - "synced": "Synkronoitu", - "plain": "Tavallinen", - "shuffle": "Sekoita", - "search_tracks": "Hae kappaleita...", - "released": "Julkaistu", - "error": "Virhe {error}", - "title": "Otsikko", - "time": "Aika", - "more_actions": "Lisää toimintoja", - "download_count": "Lataa ({count})", - "add_count_to_playlist": "Lisää ({count}) Soittolistaasi", - "add_count_to_queue": "Lisää ({count}) Jonoon", - "play_count_next": "Soita ({count}) seuraavaksi", - "album": "Albumi", - "copied_to_clipboard": "Kopioitiin {data} leikepöytään", - "add_to_following_playlists": "Lisää {track} seuraaviin soittolistoihin", - "add": "Lisää", - "added_track_to_queue": "Lisättiin {track} jonoon", - "add_to_queue": "Lisää jonoon", - "track_will_play_next": "{track} Soitetaan seuraavaksi", - "play_next": "Soita seuraavaksi", - "removed_track_from_queue": "Poistettiin {track} jonosta", - "remove_from_queue": "Poista jonosta", - "remove_from_favorites": "Poista suosikeista", - "save_as_favorite": "Tallenna soittolistana", - "add_to_playlist": "Lisää soittolistaan", - "remove_from_playlist": "Poista soittolistasta", - "add_to_blacklist": "Lisää mustalle listalle", - "remove_from_blacklist": "Poista mustalistalta", - "share": "Jaa", - "mini_player": "Minisoitin", - "slide_to_seek": "Liu'uta mennäkseen eteenpäin tai taaksepäin", - "shuffle_playlist": "Sekoita soittolista", - "unshuffle_playlist": "Poista sekoitus soittolistasta", - "previous_track": "Äskeinen kappale", - "next_track": "Seuraava kappale", - "pause_playback": "Pysäytä soittolistan toisto", - "resume_playback": "Jatka soittolistan toistoa", - "loop_track": "Uudelleentoista kappale", - "repeat_playlist": "Toista soittolista uudelleen", - "queue": "Jono", - "alternative_track_sources": "Toinen kappale lähde", - "download_track": "Lataa kappale", - "tracks_in_queue": "{tracks} kappaletta jonossa", - "clear_all": "Tyhjennä kaikki", - "show_hide_ui_on_hover": "Näytä/Piilota UI leijumalla", - "always_on_top": "Aina päällimmäisenä", - "exit_mini_player": "Lähde minisoittimesta", - "download_location": "Lataus sijainti", - "account": "Käyttäjä", - "login_with_spotify": "Kirjaudu Spotify-käyttäjällä", - "connect_with_spotify": "Yhdistä Spotify:lla", - "logout": "Kirjaudu ulos", - "logout_of_this_account": "Kirjaudu ulos tältä käyttäjältä", - "language_region": "Kieli ja Maa", - "language": "Kieli", - "system_default": "Järjestelmän oletus", - "market_place_region": "Markkina-alue", - "recommendation_country": "Suositeltu maa", - "appearance": "Ulkomuto", - "layout_mode": "Asettelutila", - "override_layout_settings": "Jätä reagoiva asettelutila huomioimatta", - "adaptive": "Mukautuva", - "compact": "Kompakti", - "extended": "Laajennettu", - "theme": "Teema", - "dark": "Tumma", - "light": "Vaalea", - "system": "Järjestelmä", - "accent_color": "Korostusväri", - "sync_album_color": "Synkronoi albumin väri", - "sync_album_color_description": "Käyttää albumin kansitaiteen vallitsevaa väirä korostuvärinä", - "playback": "Toisto", - "audio_quality": "Äänenlaatu", - "high": "Korkea", - "low": "Matala", - "pre_download_play": "Esilataa ja soita", - "pre_download_play_description": "Audion suoratoiston sijaan, lataa tavut ja soita ne (Suositeltu korkeamman kaistanleveyden käyttäjille)", - "skip_non_music": "Ohita ei-musiikki kohdat (SponsorBlock)", - "blacklist_description": "Mustalistat kappaleet aja artistit", - "wait_for_download_to_finish": "Odota nykyisen latauksen lopetteluun", - "desktop": "Työpöytä", - "close_behavior": "Sulkemisen käyttäytyminen", - "close": "Sulje", - "minimize_to_tray": "Minimisoi tehtäväpalkkiin", - "show_tray_icon": "Näytä järjestelmäkuvake", - "about": "Tietoa", - "u_love_spotube": "Tiedämme että rakastat Spotubea", - "check_for_updates": "Tarkista päivitykset", - "about_spotube": "Tietoa Spotube:sta", - "blacklist": "Mustalista", - "please_sponsor": "Sponsoroi/Lahjoita, kiitos", - "spotube_description": "Spotube, kevyt, cross-platform, vapaa-kaikille spotify clientti", - "version": "Versio", - "build_number": "Rakennusnumero", - "founder": "Perustaja", - "repository": "Arkisto", - "bug_issues": "Bugit+Ongelmat", - "made_with": "Tehty ❤️ Bangladeshista 🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Lisenssi", - "add_spotify_credentials": "Lisää Spotify-tunnuksesi aloittaaksesi", - "credentials_will_not_be_shared_disclaimer": "Älä huoli, tunnuksiasi ei talleteta tai jaeta kenenkään kanssa", - "know_how_to_login": "Etkö tiedä miten tehdä tämä?", - "follow_step_by_step_guide": "Seuraa askel askeleelta opasta", - "spotify_cookie": "Spotify {name} Keksi", - "cookie_name_cookie": "{name} Keksi", - "fill_in_all_fields": "Täytä kaikki kentät", - "submit": "Lähetä", - "exit": "Poistu", - "previous": "Edellinen", - "next": "Seuraava", - "done": "Tehty", - "step_1": "Vaihe 1", - "first_go_to": "Ensiksi, mene", - "login_if_not_logged_in": "ja Kirjaudu/Tee tili jos et ole kirjautunut sisään", - "step_2": "Vaihe 2", - "step_2_steps": "1. Kun olet kirjautunut, paina F12 tai oikeaa hiiren näppäintä > Tarkista ja avaa selaimen kehittäjä työkalut.\n2. Mene sitten \"Application\"-välilehteen (Chrome, Edge, Brave jne..) tai \"Storage\"-välilehteen (Firefox, Palemoon jne..)\n3. Mene \"Cookies\"-osastoon, sitten \"https://accounts.spotify.com\" alakohtaan.", - "step_3": "Vaihe 3", - "step_3_steps": "Kopioi Keksin \"sp_dc\" arvo", - "success_emoji": "Onnistuit🥳", - "success_message": "Olet nyt kirjautunut sisään Spotify-käyttäjällesi. Hyvää työtä toveri!", - "step_4": "Vaihe 4", - "step_4_steps": "Liitä kopioitu \"sp_dc\" arvo", - "something_went_wrong": "Jotain meni pieleen", - "piped_instance": "Johdettu palvelinesiintymä", - "piped_description": "Johdettu palvelinesiintymä Kappale täsmäyksiin", - "piped_warning": "Jotkut niistä eivät toimi hyvin, käytä siis omalla vastuullasi", - "generate_playlist": "Tuota soittolista", - "track_exists": "Kappale {track} on jo olemassa!", - "replace_downloaded_tracks": "Korvaa kaikki ladatut kappaleet", - "skip_download_tracks": "Ohita ladattujen laulujen lataaminen", - "do_you_want_to_replace": "Haluatko korvata olemassa olevan kappaleen??", - "replace": "Korvaa", - "skip": "Ohita", - "select_up_to_count_type": "Valitse enintään {count} {type}", - "select_genres": "Valitse Genret", - "add_genres": "Lisää Genrejä", - "country": "Maa", - "number_of_tracks_generate": "Numero tuotettavia kappaleita", - "acousticness": "Akustisuus", - "danceability": "Tanssittavuus", - "energy": "Energia", - "instrumentalness": "Instrumentaalisuus", - "liveness": "Elävyyttä", - "loudness": "Äänekkyys", - "speechiness": "Puheisuus", - "valence": "Valenssi", - "popularity": "Suosio", - "key": "Sävellaji", - "duration": "Pituus (s)", - "tempo": "Tempo (BPM)", - "mode": "Tila", - "time_signature": "Aikamerkki", - "short": "Lyhyt", - "medium": "Keskikokoinen", - "long": "Pitkä", - "min": "Minimi", - "max": "Maximi", - "target": "Kohde", - "moderate": "Kohtalainen", - "deselect_all": "Poista kaikki valinnat", - "select_all": "Valitse kaikki", - "are_you_sure": "Oletko varma?", - "generating_playlist": "Luodaan mukautettua soittolistoa...", - "selected_count_tracks": "Valittu {count} kappaletta", - "download_warning": "Jos lataat kaikki laulut kerrällä olet selkeästi Piratoimassa ja aiheuttamassa vahinkoa musiikin luovaan yhteiskuntaan. Toivottavasti olet tietoinen tästä. Yritä aina kunnioittaa ja tukea Artistin kovaa työtä.", - "download_ip_ban_warning": "BTW, YouTube voi estää IP-Osoitteesi tavallista liiallisten latauspyyntöjen takia. IP-Osoitteen esto tarkoittaa sitä, ettet voi käyttää YouTubea (vaikka olisit kirjautunut) vähintään 2-3kk aikana kyseiseltä laitteelta. Spotube ei kanna yhtään vastuuta jos se tapahtuu.", - "by_clicking_accept_terms": "Painamalla 'hyväksy' hyväksyt seuraaviin ehtoihin:", - "download_agreement_1": "Tiedän että Piratoin musiikkia. Olen paha.", - "download_agreement_2": "Tuen Artisteja silloin kun pystyn, ja teen tämän vain koska minulla ei ole rahaa ostaa heidän taidetta", - "download_agreement_3": "Ymmärrän että minun YouTube voi estää IP-Osoitteeni ja en pidä Spotubea tai omistajiinsa/avustajia vastuullisena mistään omista teoistsani", - "decline": "Hylkää", - "accept": "Hyväksy", - "details": "Yksityiskohdat", - "youtube": "YouTube", - "channel": "Kanava", - "likes": "Tykkäykset", - "dislikes": "Epä-tykkäykset", - "views": "Näyttökerrat", - "streamUrl": "Suoratoiston URL", - "stop": "Lopeta", - "sort_newest": "Suodata uusimmista", - "sort_oldest": "Suodata vanhimmista", - "sleep_timer": "Uniajastin", - "mins": "{minutes} Minuuttia", - "hours": "{hours} Tuntia", - "hour": "{hours} Tunti", - "custom_hours": "Mukautetut tunnit", - "logs": "Lokit", - "developers": "Kehittäjät", - "not_logged_in": "Et ole kirjautunut sisään.", - "search_mode": "Hakutila", - "audio_source": "Äänilähde", - "ok": "Ok", - "failed_to_encrypt": "Salaaminen epäonnistui", - "encryption_failed_warning": "Spotube käyttää salausta tallentaakseen tietosi, mutta epäonnistui, joten se palaa epäturvalliseen tallennukseen\nJos käytät Linuxia, varmista että sinulla on turvallisuuspalvelu (gnome-keyring, kde-wallet, keepassxc jne) asennettu", - "querying_info": "Hankitaan tietoa...", - "piped_api_down": "Johdettu palvelinesiintymä on alhaalla", - "piped_down_error_instructions": "Johdettu palvelinesiintymä {pipedInstance} on alhaalla.\n\nVaihda joko ilmeytymä tia vahda 'API tyyppi' YouTuben viralliseen API\n\nKäynnistä sovellus uudestaan vaihdon jälkeen", - "you_are_offline": "Et ole yhdistetty verkkoon", - "connection_restored": "Verkkoyhteys palautettu", - "use_system_title_bar": "Käytä järjestelmäpalkkia", - "crunching_results": "Paloitellaan tuloksia...", - "search_to_get_results": "Hae saadakseen tuloksia", - "use_amoled_mode": "Pilkkopimeä tumma teema", - "pitch_dark_theme": "AMOLED Tila", - "normalize_audio": "Normalisoi audio", - "change_cover": "Vaihda koveri", - "add_cover": "Lisää koveri", - "restore_defaults": "Palauta oletukset", - "download_music_codec": "Ladatun musiikin codefc", - "streaming_music_codec": "Suoratoistetun musiikin codec", - "login_with_lastfm": "Kirjaudu sisään Last.fm:llä", - "connect": "Yhdistä", - "disconnect_lastfm": "Katkaise Last.fm", - "disconnect": "Katkaise", - "username": "Käyttäjänimi", - "password": "Salasana", - "login": "Kirjaudu", - "login_with_your_lastfm": "Kirjaudu Last.fm käyttäjälläsi", - "scrobble_to_lastfm": "Scrobble Last.fm:ään", - "go_to_album": "Mene albumiin", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "Selaa kaikki", - "genres": "Genret", - "explore_genres": "Seikkaile genrejä", - "friends": "Kaverit", - "no_lyrics_available": "Anteeksi, emme löytäneet lyriikoita tälle laululle", - "start_a_radio": "Aloita Radio", - "how_to_start_radio": "Kuinka haluat aloittaa radion?", - "replace_queue_question": "Haluatko korvata nykyisen jonon vai lisätä siihen?", - "endless_playback": "Loputon toisto", - "delete_playlist": "Poista soittolista", - "delete_playlist_confirmation": "Oletko varma että haluat poistaa tämän soittolistan?", - "local_tracks": "Paikalliset kappaleet", - "song_link": "Laulun linkki", - "skip_this_nonsense": "Ohita tämä hölynpöly", - "freedom_of_music": "“Musiikin vapaus”", - "freedom_of_music_palm": "“Musiikin vapaus käsissäsi”", - "get_started": "Aloitetaan", - "youtube_source_description": "Suositeltu ja toimii parhaiten.", - "piped_source_description": "Tuntuuko vapaalta? Sama kuin YouTube mutta paljon vapautta", - "jiosaavn_source_description": "Paras Etelä-Aasian alueelle.", - "highest_quality": "Korkein laatu: {quality}", - "select_audio_source": "Valitse äänilähde", - "endless_playback_description": "Lisää automaattisesti uusia lauluja\njonon perään", - "choose_your_region": "Valitse alueesi", - "choose_your_region_description": "Tämä auttaa Spotube näyttämään sinulle oikeaa sisältöä\nsijaintiasi varten.", - "choose_your_language": "Valitse kielesi", - "help_project_grow": "Auta tätä projektia kasvamaan", - "help_project_grow_description": "Spotube projekti minkä lähdekoodi on julkisesti saatavilla. Voit autta tätä projektia kasvamaan muutoksilla, ilmoittamalla bugeista, tai ehdottamalla uusia ominaisuuksia.", - "contribute_on_github": "Auta GitHub:ssa", - "donate_on_open_collective": "Lahjoita avoimessa kollektiivissa", - "browse_anonymously": "Selaa anonyyminä", - "enable_connect": "Ota käyttöön yhdistäminen", - "enable_connect_description": "Ohjaa Spotubea toiselta laitteelta", - "devices": "Laitteet", - "select": "Valitse", - "connect_client_alert": "{client} ohjaa sinua", - "this_device": "Tämä laite", - "remote": "Etä", - "local_library": "Paikallinen kirjasto", - "add_library_location": "Lisää kirjastoon", - "remove_library_location": "Poista kirjastosta", - "local_tab": "Paikallinen", - "stats": "Tilastot" -} \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb deleted file mode 100644 index 669f5e2a..00000000 --- a/lib/l10n/app_id.arb +++ /dev/null @@ -1,329 +0,0 @@ -{ - "guest": "Tamu", - "browse": "Jelajahi", - "search": "Cari", - "library": "Pustaka", - "lyrics": "Lirik", - "settings": "Pengaturan", - "genre_categories_filter": "Urutkan kategori atau genre...", - "genre": "Genre", - "personalized": "Dipersonalisasi", - "featured": "Unggulan", - "new_releases": "Rilis Terbaru", - "songs": "Lagu", - "playing_track": "Memutar {track}", - "queue_clear_alert": "Ini akan menghapus antrian saat ini This will clear the current queue. {track_length} trek akan dihapus\nAnda ingin melanjutkan?", - "load_more": "Lebih Banyak", - "playlists": "Daftar Putar", - "artists": "Artis", - "albums": "Album", - "tracks": "Trek", - "downloads": "Unduhan", - "filter_playlists": "Urutkan daftar putar Anda...", - "liked_tracks": "Lagu Yang Disukai", - "liked_tracks_description": "Semua lagu yang Anda sukai", - "create_playlist": "Buat Daftar Putar", - "create_a_playlist": "Buat daftar putar", - "update_playlist": "Ubah daftar putar", - "create": "Buat", - "cancel": "Batal", - "update": "Ubah", - "playlist_name": "Nama Daftar Putar", - "name_of_playlist": "Nama daftar putar", - "description": "Deskripsi", - "public": "Publik", - "collaborative": "Kolaboratif", - "search_local_tracks": "Cari trek lokal...", - "play": "Putar", - "delete": "Hapus", - "none": "Tidak Ada", - "sort_a_z": "Urutkan berdasarkan A-Z", - "sort_z_a": "Urutkan berdasarkan Z-A", - "sort_artist": "Urutkan berdasarkan Artis", - "sort_album": "Urutkan berdasarkan Album", - "sort_duration": "Urutkan berdasarkan Durasi", - "sort_tracks": "Urutkan trek", - "currently_downloading": "Sedang Mengunduh ({tracks_length})", - "cancel_all": "Batalkan Semua", - "filter_artist": "Urutkan artis...", - "followers": "{followers} Pengikut", - "add_artist_to_blacklist": "Tambah artis ke daftar hitam", - "top_tracks": "Lagu Teratas", - "fans_also_like": "Penggemar juga menyukainya", - "loading": "Memuat...", - "artist": "Artis", - "blacklisted": "Masuk Daftar Hitam", - "following": "Mengikuti", - "follow": "Ikuti", - "artist_url_copied": "URL artis telah disalin", - "added_to_queue": "Menambah trek {tracks} ke antrean", - "filter_albums": "Urutkan album...", - "synced": "Disinkronkan", - "plain": "Normal", - "shuffle": "Acak", - "search_tracks": "Cari trek...", - "released": "Dirilis", - "error": "Kesalahan {error}", - "title": "Judul", - "time": "Waktu", - "more_actions": "Tindakan Lainnya", - "download_count": "Unduhan ({count})", - "add_count_to_playlist": "Menambah ({count}) ke Daftar Putar", - "add_count_to_queue": "Menambah ({count}) ke Antrian", - "play_count_next": "Mainkan ({count}) selanjutnya", - "album": "Album", - "copied_to_clipboard": "{data} telah disalin", - "add_to_following_playlists": "Menambah {track} ke Daftar Putar berikut", - "add": "Tambah", - "added_track_to_queue": "Menambah {track} ke antrian", - "add_to_queue": "Tambah ke antrian", - "track_will_play_next": "{track} akan diputar berikutnya", - "play_next": "Mainkan selanjutnya", - "removed_track_from_queue": "Menghapus {track} dari antrian", - "remove_from_queue": "Hapus dari antrian", - "remove_from_favorites": "Hapus dari favorit", - "save_as_favorite": "Simpan sebagai favorit", - "add_to_playlist": "Tambah ke daftar putar", - "remove_from_playlist": "Hapus dari daftar putar", - "add_to_blacklist": "Tambah ke daftar hitam", - "remove_from_blacklist": "Hapus dari daftar hitam", - "share": "Bagikan", - "mini_player": "Pemutar Mini", - "slide_to_seek": "Geser untuk maju atau mundur", - "shuffle_playlist": "Acak daftar putar", - "unshuffle_playlist": "Batalkan pengacakan daftar putar", - "previous_track": "Lagu sebelumnya", - "next_track": "Lagu berikutnya", - "pause_playback": "Jeda Pemutaran", - "resume_playback": "Lanjutkan Pemutaran", - "loop_track": "Ulangi Pemutaran", - "repeat_playlist": "Ulangi daftar putar", - "queue": "Antrian", - "alternative_track_sources": "Sumber trek alternatif", - "download_track": "Unduh lagu", - "tracks_in_queue": "{tracks} trek dalam antrian", - "clear_all": "Bersihkan semua", - "show_hide_ui_on_hover": "Tampil/Sembunyikan UI saat mengarahkan kursor", - "always_on_top": "Selalu di atas", - "exit_mini_player": "Keluar Pemutar Mini", - "download_location": "Lokasi unduhan", - "account": "Akun", - "login_with_spotify": "Masuk dengan Spotify", - "connect_with_spotify": "Hubungkan dengan Spotify", - "logout": "Keluar", - "logout_of_this_account": "Keluar dari akun", - "language_region": "Bahasa & Wilayah", - "language": "Bahasa", - "system_default": "Bawaan Sistem", - "market_place_region": "Wilayah Pasar", - "recommendation_country": "Negara Rekomendasi", - "appearance": "Tampilan", - "layout_mode": "Mode Tata Letak", - "override_layout_settings": "Ganti pengaturan mode tata letak responsif", - "adaptive": "Adaptif", - "compact": "Ringkas", - "extended": "Diperluas", - "theme": "Tema", - "dark": "Gelap", - "light": "Terang", - "system": "Sistem", - "accent_color": "Warna Aksen", - "sync_album_color": "Sinkronkan warna album", - "sync_album_color_description": "Menggunakan warna dominan sampul album sebagai warna aksen", - "playback": "Pemutaran", - "audio_quality": "Kualitas Suara", - "high": "Tinggi", - "low": "Rendah", - "pre_download_play": "Unduh dan putar", - "pre_download_play_description": "Daripada streaming audio, unduh byte dan mainkan (Direkomendasikan untuk pengguna bandwidth yang lebih tinggi)", - "skip_non_music": "Lewati segmen non-musik (SponsorBlock)", - "blacklist_description": "Lagu dan artis di daftar hitam", - "wait_for_download_to_finish": "Tunggu hingga unduhan saat ini selesai", - "desktop": "Desktop", - "close_behavior": "Tutup Perilaku", - "close": "Tutup", - "minimize_to_tray": "Perkecil ke tray", - "show_tray_icon": "Tampilkan tray ikon sistem", - "about": "Tentang", - "u_love_spotube": "Kami tahu Anda menyukai Spotube", - "check_for_updates": "Periksa pembaruan", - "about_spotube": "Tentang Spotube", - "blacklist": "Daftar Hitam", - "please_sponsor": "Silakan Sponsor/Menyumbang", - "spotube_description": "Spotube, klien Spotify yang ringan, lintas platform, dan gratis untuk semua", - "version": "Versi", - "build_number": "Nomor Pembuatan", - "founder": "Pendiri", - "repository": "Repositori", - "bug_issues": "Bug+Masalah", - "made_with": "Dibuat dengan ❤️ di Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "Lisensi", - "add_spotify_credentials": "Tambahkan kredensial Spotify Anda untuk memulai", - "credentials_will_not_be_shared_disclaimer": "Jangan khawatir, kredensial Anda tidak akan dikumpulkan atau dibagikan kepada siapa pun", - "know_how_to_login": "Tidak tahu bagaimana melakukan ini?", - "follow_step_by_step_guide": "Ikuti panduan Langkah demi Langkah", - "spotify_cookie": "Spotify {name} Cookie", - "cookie_name_cookie": "{name} Cookie", - "fill_in_all_fields": "Silakan isi semua kolom", - "submit": "Kirim", - "exit": "Keluar", - "previous": "Sebelumnya", - "next": "Berikutnya", - "done": "Selesai", - "step_1": "Langkah 1", - "first_go_to": "Pertama, Pergi ke", - "login_if_not_logged_in": "dan Masuk/Daftar jika Anda belum masuk", - "step_2": "Langkah 2", - "step_2_steps": "1. Setelah Anda masuk, tekan F12 atau Klik Kanan Mouse > Buka Browser Devtools.\n2. Lalu buka Tab \"Aplikasi\" (Chrome, Edge, Brave, dll.) atau Tab \"Penyimpanan\" (Firefox, Palemoon, dll.)\n3. Buka bagian \"Cookie\" lalu subbagian \"https://accounts.spotify.com\"", - "step_3": "Langkah 3", - "step_3_steps": "Salin nilai Cookie \"sp_dc\" ", - "success_emoji": "Berhasil🥳", - "success_message": "Sekarang Anda telah berhasil Masuk dengan akun Spotify Anda. Kerja bagus, sobat!", - "step_4": "Langkah 4", - "step_4_steps": "Tempel nilai \"sp_dc\" yang disalin", - "something_went_wrong": "Terjadi kesalahan", - "piped_instance": "Piped Server Instance", - "piped_description": "The Piped server instance untuk digunakan sebagai pencocokan trek", - "piped_warning": "Beberapa di antaranya mungkin tidak berfungsi dengan baik. Jadi gunakan dengan risiko Anda sendiri", - "generate_playlist": "Hasilkan Daftar Putar", - "track_exists": "Lagu {track} sudah ada", - "replace_downloaded_tracks": "Ganti semua trek yang diunduh", - "skip_download_tracks": "Lewati pengunduhan semua trek yang diunduh", - "do_you_want_to_replace": "Apakah Anda ingin mengganti track yang ada?", - "replace": "Ganti", - "skip": "Lewati", - "select_up_to_count_type": "Pilih hingga {count} {type}", - "select_genres": "Pilih Genre", - "add_genres": "Tambah Genre", - "country": "Negara", - "number_of_tracks_generate": "Jumlah trek yang akan dihasilkan", - "acousticness": "Akustik", - "danceability": "Menari", - "energy": "Energi", - "instrumentalness": "Instrumentalitas", - "liveness": "Kehidupan", - "loudness": "Kekerasan", - "speechiness": "Berbicara", - "valence": "Valensi", - "popularity": "Popularitas", - "key": "Kunci", - "duration": "Durasi (s)", - "tempo": "Tempo (BPM)", - "mode": "Mode", - "time_signature": "Tanda Tangan Waktu", - "short": "Pendek", - "medium": "Sedang", - "long": "Panjang", - "min": "Minimal", - "max": "Maksimal", - "target": "Target", - "moderate": "Sedang", - "deselect_all": "Batalkan Semua", - "select_all": "Pilih Semua", - "are_you_sure": "Anda yakin?", - "generating_playlist": "Menghasilkan daftar putar khusus Anda...", - "selected_count_tracks": "{count} lagu yang dipilih", - "download_warning": "Jika Anda mengunduh semua Lagu secara massal, Anda jelas membajak Musik & menyebabkan kerusakan pada masyarakat kreatif Musik. Saya harap Anda menyadari hal ini. Selalu berusaha menghormati & mendukung kerja keras Artis", - "download_ip_ban_warning": "BTW, IP Anda bisa diblokir di YouTube karena permintaan unduhan yang berlebihan dari biasanya. Blokir IP berarti Anda tidak dapat menggunakan YouTube (meskipun Anda masuk) setidaknya selama 2-3 bulan dari perangkat IP tersebut. Dan Spotube tidak bertanggung jawab jika hal ini terjadi", - "by_clicking_accept_terms": "Dengan mengklik 'terima' Anda menyetujui ketentuan berikut:", - "download_agreement_1": "Saya tahu saya membajak Musik. Saya buruk", - "download_agreement_2": "Saya akan mendukung Artis di mana pun saya bisa dan saya melakukan ini hanya karena saya tidak punya uang untuk membeli karya seni mereka", - "download_agreement_3": "Saya sepenuhnya menyadari bahwa IP saya dapat diblokir di YouTube & saya tidak menganggap Spotube atau pemilik/kontributornya bertanggung jawab atas kecelakaan apa pun yang disebabkan oleh tindakan saya saat ini", - "decline": "Menolak", - "accept": "Setuju", - "details": "Detail", - "youtube": "YouTube", - "channel": "Channel", - "likes": "Suka", - "dislikes": "Tidak Suka", - "views": "Dilihat", - "streamUrl": "URL Stream", - "stop": "Berhenti", - "sort_newest": "Urutkan yang baru ditambah", - "sort_oldest": "Urutkan yang paling lama ditambah", - "sleep_timer": "Pengatur Waktu Tidur", - "mins": "{minutes} Menit", - "hours": "{hours} Jam", - "hour": "{hours} Jam", - "custom_hours": "Jam Kostum", - "logs": "Log", - "developers": "Pengembang", - "not_logged_in": "Anda belum masuk", - "search_mode": "Mode Pencarian", - "audio_source": "Sumber Suara", - "ok": "OK", - "failed_to_encrypt": "Gagal mengenkripsi", - "encryption_failed_warning": "Spotube menggunakan enkripsi untuk menyimpan data Anda dengan aman. Namun gagal melakukannya. Jadi itu akan kembali ke penyimpanan yang tidak aman\nJika Anda menggunakan linux, pastikan Anda telah menginstal layanan rahasia (gnome-keyring, kde-wallet, keepassxc, dll)", - "querying_info": "Mencari informasi...", - "piped_api_down": "Piped API tidak aktif", - "piped_down_error_instructions": "Piped Instance {pipedInstance} saat ini tidak aktif\n\nUbah instance atau ubah 'jenis API' menjadi API YouTube resmi\n\nPastikan untuk memulai ulang aplikasi setelah perubahan", - "you_are_offline": "Anda sedang offline", - "connection_restored": "Koneksi internet Anda telah pulih", - "use_system_title_bar": "Gunakan bilah judul sistem", - "crunching_results": "Mengolah hasil...", - "search_to_get_results": "Cari untuk mendapatkan hasil", - "use_amoled_mode": "Tema gelap gulita", - "pitch_dark_theme": "Mode AMOLED", - "normalize_audio": "Normalisasi audio", - "change_cover": "Ganti sampul", - "add_cover": "Tambah sampul", - "restore_defaults": "Kembalikan semula", - "download_music_codec": "Unduh codec musik", - "streaming_music_codec": "Streaming codec musik", - "login_with_lastfm": "Masuk dengan Last.fm", - "connect": "Hubungkan", - "disconnect_lastfm": "Memutuskan Last.fm", - "disconnect": "Memutuskan", - "username": "Username", - "password": "Password", - "login": "Masuk", - "login_with_your_lastfm": "Masuk dengan Last.fm Anda", - "scrobble_to_lastfm": "Scrobble ke Last.fm", - "go_to_album": "Pergi ke Album", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "Lihat Semua", - "genres": "Genre", - "explore_genres": "Jelajahi Genre", - "friends": "Daftar Teman", - "no_lyrics_available": "Maaf, tidak dapat menemukan lirik untuk lagu ini", - "start_a_radio": "Putar Radio", - "how_to_start_radio": "Bagaimana Anda ingin memutar radio?", - "replace_queue_question": "Apakah Anda ingin mengganti antrean saat ini atau menambahkannya?", - "endless_playback": "Pemutaran Tanpa Akhir", - "delete_playlist": "Hapus Daftar Putar", - "delete_playlist_confirmation": "Anda yakin ingin menghapus daftar putar ini?", - "local_tracks": "Trek Lokal", - "song_link": "Tautan Lagu", - "skip_this_nonsense": "Lewati omong kosong ini", - "freedom_of_music": "“Kebebasan Musik”", - "freedom_of_music_palm": "“Kebebasan Musik di telapak tangan Anda”", - "get_started": "Mari kita mulai", - "youtube_source_description": "Direkomendasikan dan berfungsi paling baik.", - "piped_source_description": "Merasa bebas? Sama seperti YouTube tetapi banyak yang gratis.", - "jiosaavn_source_description": "Terbaik untuk wilayah Asia Selatan.", - "highest_quality": "Kualitas Terbaik: {quality}", - "select_audio_source": "Pilih Sumber Suara", - "endless_playback_description": "Tambahkan lagu baru secara otomatis\nke akhir antrean", - "choose_your_region": "Pilih wilayah Anda", - "choose_your_region_description": "Ini akan membantu Spotube menampilkan konten yang tepat\nuntuk lokasi Anda.", - "choose_your_language": "Pilih bahasa Anda", - "help_project_grow": "Bantu proyek ini berkembang", - "help_project_grow_description": "Spotube adalah proyek sumber terbuka. Anda dapat membantu proyek ini berkembang dengan berkontribusi pada proyek, melaporkan bug, atau menyarankan fitur baru.", - "contribute_on_github": "Berkontribusi di GitHub", - "donate_on_open_collective": "Donasi di Open Collective", - "browse_anonymously": "Jelajahi Secara Anonim", - "enable_connect": "Aktifkan Hubungkan", - "enable_connect_description": "Kontrol Spotube dari perangkat lain", - "devices": "Perangkat", - "select": "Pilih", - "connect_client_alert": "Anda dikendalikan oleh {client}", - "this_device": "Perangkat Ini", - "remote": "Remot", - "local_library": "Perpustakaan lokal", - "add_library_location": "Tambahkan ke perpustakaan", - "remove_library_location": "Hapus dari perpustakaan", - "local_tab": "Lokal", - "stats": "Statistik" -} \ No newline at end of file diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb deleted file mode 100644 index 28fcc26a..00000000 --- a/lib/l10n/app_ka.arb +++ /dev/null @@ -1,329 +0,0 @@ -{ - "guest": "სტუმარი", - "browse": "ნახვა", - "search": "ძებნა", - "library": "ბიბლიოთეკა", - "lyrics": "ტექსტები", - "settings": "კონფიგურაციები", - "genre_categories_filter": "კატეგორიების ან ჟანრების ფილტრი...", - "genre": "ჟანრი", - "personalized": "პეერსონალიზებული", - "featured": "გამორჩეული", - "new_releases": "ახალი გამოცემები", - "songs": "სიმღერები", - "playing_track": "უკრავს {track}", - "queue_clear_alert": "ეს გაასუფთავებს მიმდინარე რიგს. {track_length} ტრეკი წაიშლება\nᲒინდა გააგრძელო?", - "load_more": "მეტის ჩატვირთვა", - "playlists": "ფლეილისტები", - "artists": "არტისტები", - "albums": "ალბომები", - "tracks": "ტრეკები", - "downloads": "ჩამოტვირთვები", - "filter_playlists": "ფლეილისტების გაფილტვრა...", - "liked_tracks": "მოწონებული ტრეკები", - "liked_tracks_description": "ყველა შენი მოწონებული ტრეკი", - "create_playlist": "ფლეილისტის შექმნა", - "create_a_playlist": "ფლეილისტის შექმნა", - "update_playlist": "ფლეილისტის განახლება", - "create": "შექმნა", - "cancel": "გაუქმება", - "update": "განახლება", - "playlist_name": "ფლეილისტის სახელი", - "name_of_playlist": "ფლეილისტის სახელი", - "description": "აღწერა", - "public": "საჯარო", - "collaborative": "კოლაბორაციული", - "search_local_tracks": "ლოცალური ტრეკების ძებნა...", - "play": "დაკვრა", - "delete": "წაშლა", - "none": "არცერთი", - "sort_a_z": "დალაგება A-Z-ს მიხედვით", - "sort_z_a": "დალაგება Z-A-ს მიხედვით", - "sort_artist": "დალაგება არტისტის მიხედვით", - "sort_album": "დალაგება ალბომის მიხედვით", - "sort_duration": "დალაგება ხანგრძლივობის მიხედვით", - "sort_tracks": "ტრეკების დალაგება", - "currently_downloading": "მიმდინარეობს ჩამოტვირთვა ({tracks_length})", - "cancel_all": "ყველას გაუქმება", - "filter_artist": "არტისტების ფილტრი...", - "followers": "{followers} ფოლოვერები", - "add_artist_to_blacklist": "არტისტის შავ სიაში დამატება", - "top_tracks": "ტოპ ტრეკები", - "fans_also_like": "ფანებს ასევე მოსწონთ", - "loading": "იტვირთება...", - "artist": "არტისტი", - "blacklisted": "შავ სიაში მყოფი", - "following": "ფოლოვინგი", - "follow": "დაფოლოვება", - "artist_url_copied": "არტისტის ლინკი დაკოპირებულია", - "added_to_queue": "{tracks} ტრეკი დაემატა რიგში", - "filter_albums": "ალბომების გაფილტვრა...", - "synced": "სინქრონიზებული", - "plain": "Plain", - "shuffle": "რიგის არევა", - "search_tracks": "ტრეკების ძებნა...", - "released": "გამოშვებული", - "error": "შეცდომა {error}", - "title": "სათაური", - "time": "დრო", - "more_actions": "მეტი მოქმედებები", - "download_count": "გადმოწერა ({count})", - "add_count_to_playlist": "ფლეილისტში ({count})-ის დამატება", - "add_count_to_queue": "რიგში ({count})-ის დამატება", - "play_count_next": "შემდეგი ({count})-ის დაკვრა", - "album": "ალბომი", - "copied_to_clipboard": "{data} დაკოპირებულია", - "add_to_following_playlists": "დაამატე {track} ამ ფლეილისტებში", - "add": "დამატება", - "added_track_to_queue": "რიგში დაემატა {track}", - "add_to_queue": "რიგში დამატება", - "track_will_play_next": "{track} დაუკრავს შემდეგს", - "play_next": "შემდეგის დაკვრა", - "removed_track_from_queue": "რიგიდან წაიშალა {track}", - "remove_from_queue": "რიგიდან წაშლა", - "remove_from_favorites": "ფავორიტებიდან წაშლა", - "save_as_favorite": "ფავორიტებში დამატება", - "add_to_playlist": "ფლეილისტში დამატება", - "remove_from_playlist": "ფლეილისტიდან წაშლა", - "add_to_blacklist": "შავ სიაში დამატება", - "remove_from_blacklist": "შავი სიიდან წაშლა", - "share": "გაზიარება", - "mini_player": "მინი დამკვრელი", - "slide_to_seek": "გადახვევისთვის გაასრიალეთ წინ ან უკან", - "shuffle_playlist": "ფლეილისტის არევა", - "unshuffle_playlist": "ფლეილისტის დალაგება", - "previous_track": "წინა ტრეკი", - "next_track": "შემდეგი ტრეკი", - "pause_playback": "დაკვრის გაჩერება", - "resume_playback": "დაკვრის გაგრძელება", - "loop_track": "ტრეკის ლუპზე დაკვრა", - "repeat_playlist": "ფლეილისტის გამეორება", - "queue": "რიგი", - "alternative_track_sources": "ალტერნატიული ტრეკების წყაროები", - "download_track": "გადმოწერე ტრეკი", - "tracks_in_queue": "{tracks} ტრეკი რიგში", - "clear_all": "ყველას წაშლა", - "show_hide_ui_on_hover": "UI-ის ჩვენება/დამალვა ჰოვერზე", - "always_on_top": "ტოველთვის ზემოდან", - "exit_mini_player": "მინი დამკვრელიდან გამოსვლა", - "download_location": "ჩამოტვირთვის მდებარეობა", - "account": "ანგარიში", - "login_with_spotify": "შედით თქვენი Spotify ანგარიშით", - "connect_with_spotify": "დაუკავშირდით Spotify-ს", - "logout": "გასვლა", - "logout_of_this_account": "ანგარიშიდან გასვლა", - "language_region": "ენა და რეგიონი", - "language": "ენა", - "system_default": "სისტემის ნაგულისხმევი", - "market_place_region": "მარკეტფლეისის რეგიონი", - "recommendation_country": "რეკომენდირებული ქვეყანა", - "appearance": "გარეგნობა", - "layout_mode": "განლაგების რეჟიმი", - "override_layout_settings": "რესფონსივ განლაგების რეჟიმის კონფიგურაციაზე გადაწერა", - "adaptive": "ადაპტირებული", - "compact": "კომპაქტური", - "extended": "გაფართოებული", - "theme": "თემა", - "dark": "ბნელი", - "light": "ღია", - "system": "სისტემის", - "accent_color": "აქცენტის ფერი", - "sync_album_color": "ალბომის ფერის სინქრონიზაცია", - "sync_album_color_description": "დომინანტური ალბომის ფერის აქცენტის ფერად გამოყენება", - "playback": "დაკვრა", - "audio_quality": "აუდიოს ხარისხი", - "high": "მაღალი", - "low": "დაბალი", - "pre_download_play": "წინასწარ ჩამოტვირთვა და დაკვრა", - "pre_download_play_description": "აუდიოს სტრიმინგის ნაცვლად, ბაიტების ჩამოტვირთვა და დაკვრა (რეკომენდებულია უფრო მაღალი გამტარუნარიანობის მომხმარებლებისთვის)", - "skip_non_music": "არა მუსიკალური ნაწილის გამოტოვება (სპონსორის ბლოკი)", - "blacklist_description": "შავ სიაში მყოფი არტისტები და ტრეკები", - "wait_for_download_to_finish": "გთხოვთ, დაელოდოთ მიმდინარე ჩამოტვირთვის დასრულებას", - "desktop": "დესკტოპი", - "close_behavior": "დახურვის ქცევა", - "close": "დახურვა", - "minimize_to_tray": "მინიმიზაცია", - "show_tray_icon": "სისტემის აიკონის ჩვენება", - "about": "ჩვენს შესახებ", - "u_love_spotube": "We know you love Spotube", - "check_for_updates": "განახლებების შემოწმება", - "about_spotube": "Spotube-ს შესახებ", - "blacklist": "შავი სია", - "please_sponsor": "გთხოვთ დაგვასპონსოროთ", - "spotube_description": "Spotube, a lightweight, cross-platform, free-for-all spotify client", - "version": "ვერსია", - "build_number": "Build Number", - "founder": "დამფუძნებელი", - "repository": "რეპოზიტორია", - "bug_issues": "Bug+Issues", - "made_with": "Made with ❤️ in Bangladesh🇧🇩", - "kingkor_roy_tirtho": "Kingkor Roy Tirtho", - "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", - "license": "ლიცენზია", - "add_spotify_credentials": "დასაწყებად დაამატეთ თქვენი Spotify მონაცემები", - "credentials_will_not_be_shared_disclaimer": "არ ინერვიულოთ, თქვენი მონაცემები არ იქნება შეგროვებული ან გაზიარებული ვინმესთან", - "know_how_to_login": "არ იცით როგორ გააკეთოთ ეს?", - "follow_step_by_step_guide": "მიჰყევით ნაბიჯ-ნაბიჯ სახელმძღვანელოს", - "spotify_cookie": "Spotify {name} ქუქი", - "cookie_name_cookie": "{name} ქუქი", - "fill_in_all_fields": "გთხოვთ შეავსოთ ყველა ველი", - "submit": "გაგზავნა", - "exit": "გამოსვლა", - "previous": "წინა", - "next": "შემდეგი", - "done": "მზადაა", - "step_1": "ნაბიჯი 1", - "first_go_to": "პირველი, გადადით", - "login_if_not_logged_in": "და შესვლა/რეგისტრაცია, თუ არ ხართ შესული", - "step_2": "ნაბიჯი 2", - "step_2_steps": "1. როცა შეხვალთ, დააჭირეთ F12-ს ან მაუსის მარჯვენა ღილაკს > Inspect to Open the Browser devtools.\n2. შემდეგ გახსენით \"Application\" განყოფილება (Chrome, Edge, Brave etc..) ან \"Storage\" განყოფილება (Firefox, Palemoon etc..)\n3. შედით \"Cookies\" სექციაში და შემდეგ \"https://accounts.spotify.com\" სუბსექციაში", - "step_3": "ნაბიჯი 3", - "step_3_steps": "დააკოპირეთ \"sp_dc\" ქუქი-ფაილის მნიშვნელობა", - "success_emoji": "წარმატება🥳", - "success_message": "თქვენ წარმატებით შეხვედით თქვენი Spotify ანგარიშით.", - "step_4": "ნაბიჯი 4", - "step_4_steps": "ჩასვით კოპირებული \"sp_dc\" მნიშვნელობა", - "something_went_wrong": "Რაღაც არასწორად წავიდა", - "piped_instance": "Piped Server Instance", - "piped_description": "The Piped server instance to use for track matching", - "piped_warning": "ზოგიერთი მათგანმა შეიძლება კარგად არ იმუშაოს. ", - "generate_playlist": "ფლეილისტის დაგენერირება", - "track_exists": "ტრეკი {track} უკვე არსებობს", - "replace_downloaded_tracks": "ყველა ჩამოტვირთული ტრეკის შეცვლა", - "skip_download_tracks": "ყველა ჩამოტვირთული ტრეკის გამოტოვება", - "do_you_want_to_replace": "გსურთ შეცვალოთ არსებული ტრეკი??", - "replace": "შეცვლა", - "skip": "გამოტოვება", - "select_up_to_count_type": "აირჩიე {count}-მდე {type}", - "select_genres": "ჟანრების არჩევა", - "add_genres": "ჟანრების დამატება", - "country": "ქვეყანა", - "number_of_tracks_generate": "დასაგენერირებელი ტრეკების რაოდენობა", - "acousticness": "Acousticness", - "danceability": "Danceability", - "energy": "Energy", - "instrumentalness": "Instrumentalness", - "liveness": "Liveness", - "loudness": "Loudness", - "speechiness": "Speechiness", - "valence": "Valence", - "popularity": "Popularity", - "key": "Key", - "duration": "Duration (s)", - "tempo": "Tempo (BPM)", - "mode": "Mode", - "time_signature": "Time Signature", - "short": "Short", - "medium": "საშუალო", - "long": "გრძელი", - "min": "მინიმალური", - "max": "მაქსიმალური", - "target": "სამიზნე", - "moderate": "საშუალო", - "deselect_all": "ყველა მონიშვნის გაუქმება", - "select_all": "ყველას მონიშვნა", - "are_you_sure": "Დარწმუნებული ხართ?", - "generating_playlist": "მიმდინარეობს თქვენი მორგებული ფლეილისტის გენერირება...", - "selected_count_tracks": "არჩეულია {count} ტრეკი", - "download_warning": "If you download all Tracks at bulk you're clearly pirating Music & causing damage to the creative society of Music. I hope you are aware of this. Always, try respecting & supporting Artist's hard work", - "download_ip_ban_warning": "BTW, your IP can get blocked on YouTube due excessive download requests than usual. IP block means you can't use YouTube (even if you're logged in) for at least 2-3 months from that IP device. And Spotube doesn't hold any responsibility if this ever happens", - "by_clicking_accept_terms": "By clicking 'accept' you agree to following terms:", - "download_agreement_1": "I know I'm pirating Music. I'm bad", - "download_agreement_2": "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art", - "download_agreement_3": "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action", - "decline": "უარყოფა", - "accept": "დათანხმება", - "details": "დეტალები", - "youtube": "YouTube", - "channel": "Channel", - "likes": "მოწონებები", - "dislikes": "არ მოწონებები", - "views": "ნახვები", - "streamUrl": "სტრიმის ლინკი", - "stop": "გაჩერება", - "sort_newest": "ფალაგება სიახლის მიხედიტ", - "sort_oldest": "დალაგება სიძველის მიხედვით", - "sleep_timer": "ძილის ტაიმერი", - "mins": "{minutes} წუთი", - "hours": "{hours} საათი", - "hour": "{hours} საათი", - "custom_hours": "მორგებული საათები", - "logs": "ლოგები", - "developers": "დეველოპერები", - "not_logged_in": "არ ხარ დალოგინებული", - "search_mode": "ძებნის რეჟიმი", - "audio_source": "აუდიოს წყარო", - "ok": "ოკ", - "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", - "querying_info": "Querying info...", - "piped_api_down": "Piped API is down", - "piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change", - "you_are_offline": "ამჟამად ხაზგარეშე ხართ", - "connection_restored": "თქვენი ინტერნეტ კავშირი აღდგა", - "use_system_title_bar": "სისტემის სათაურის ზოლის გამოყენება", - "crunching_results": "იტვირთება შედეგები...", - "search_to_get_results": "მოძებნეთ შედეგების მისაღებად", - "use_amoled_mode": "Pitch black dark theme", - "pitch_dark_theme": "AMOLED Mode", - "normalize_audio": "აუდიოს ნორმალიზება", - "change_cover": "Ქავერის შეცვლა", - "add_cover": "Ქავერის ფოტოს დამატება", - "restore_defaults": "ნაგულისხმევი პარამეტრების აღდგენა", - "download_music_codec": "მუსიკის კოდეკის გადმოწერა", - "streaming_music_codec": "სტრიმინგ მუსიკის კოდეკი", - "login_with_lastfm": "Last.fm-ით შესვლა", - "connect": "დაკავშირება", - "disconnect_lastfm": "Last.fm-იდან გამოსვლა", - "disconnect": "გამოსვლა", - "username": "მომხმარებელი", - "password": "პაროლი", - "login": "შესვლა", - "login_with_your_lastfm": "Last.fm ანგარიშით შესვლა", - "scrobble_to_lastfm": "Scrobble to Last.fm", - "go_to_album": "ალბომზე გადასვლა", - "discord_rich_presence": "Discord Rich Presence", - "browse_all": "ყველას ნახვა", - "genres": "ჟანრები", - "explore_genres": "შეისწავლეთ ჟანრები", - "friends": "მეგობრები", - "no_lyrics_available": "უკაცრავად, ამ ტრეკისთვის ტექსტის პოვნა შეუძლებელია", - "start_a_radio": "რადიოს ჩართვა", - "how_to_start_radio": "როგორ გნებავთ რადიოს ჩართვა?", - "replace_queue_question": "გნებავთ ჩაანაცვლოთ არსებული რიგი თუ დაამატოთ მასზე?", - "endless_playback": "დაუსრულებელი დაკვრა", - "delete_playlist": "ფლეილისტის წაშლა", - "delete_playlist_confirmation": "დარწმუნებული ხართ რომ გნებავთ ფლეილისტის წაშლა?", - "local_tracks": "ლოკალური ტრეკები", - "song_link": "ტრეკის ლინკი", - "skip_this_nonsense": "ამ სისულელის გამოტოვება", - "freedom_of_music": "“მუსიკის თავისუფლება”", - "freedom_of_music_palm": "“მუსიკის თავისუფლება შენს ხელის გულზე”", - "get_started": "დავიწყოთ", - "youtube_source_description": "რეკომენდებულია და მუშაობს საუკეთესოდ.", - "piped_source_description": "თავისუფლად გრძნობთ თავს? იგივეა, რაც YouTube, მაგრამ ბევრი თავისუფალი.", - "jiosaavn_source_description": "საუკეთესოა სამხრეთ აზიის რეგიონისთვის.", - "highest_quality": "საუკეთესო ხარისხი: {quality}", - "select_audio_source": "აუდიოს წყაროს არჩევა", - "endless_playback_description": "ახალი სიმთერების ავტომატურად რიგის ბოლოში დამატება", - "choose_your_region": "აირჩიე შენი რეგიონი", - "choose_your_region_description": "This will help Spotube show you the right content\nfor your location.", - "choose_your_language": "აირჩიე ენა", - "help_project_grow": "დაეხმარეთ ამ პროექტს განვითარებაში", - "help_project_grow_description": "Spotube is an open-source project. You can help this project grow by contributing to the project, reporting bugs, or suggesting new features.", - "contribute_on_github": "GitHub-ზე კონტრიბუცია", - "donate_on_open_collective": "Open Collective-ზე დონაცია", - "browse_anonymously": "ანონიმურად ნახვა", - "enable_connect": "დაკავშირების ჩართვა", - "enable_connect_description": "აკონტროლე Spotube სხვა მოწყობილობებიდან", - "devices": "მოწყობილობები", - "select": "არჩევა", - "connect_client_alert": "თქვენ კონტროლირებული ხართ {client} მოწყობილობით", - "this_device": "ეს მოწყობილობა", - "remote": "დისტანციური", - "local_library": "ადგილობრივი ბიბლიოთეკა", - "add_library_location": "ბიბლიოთეკაში დამატება", - "remove_library_location": "ბიბლიოთეკიდან წაშლა", - "local_tab": "ადგილობრივი", - "stats": "სტატისტიკა" -} \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index b329cfa7..a4050853 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -3,13 +3,13 @@ "browse": "Göz at", "search": "Ara", "library": "Kütüphane", - "lyrics": "Şarkı sözleri", + "lyrics": "Şarkı Sözleri", "settings": "Ayarlar", - "genre_categories_filter": "Kategorileri veya türleri filtreleyin...", + "genre_categories_filter": "Kategorileri veya türleri filtrele...", "genre": "Tür", "personalized": "Kişiselleştirilmiş", - "featured": "Öne çıkanlar", - "new_releases": "Yeni çıkanlar", + "featured": "Öne Çıkanlar", + "new_releases": "Yeni Çıkanlar", "songs": "Şarkılar", "playing_track": "{track} oynatılıyor", "queue_clear_alert": "Bu, mevcut kuyruğu temizleyecektir. {track_length} parça kaldırılacak\nDevam etmek istiyor musunuz?", @@ -20,15 +20,15 @@ "tracks": "Parçalar", "downloads": "İndirilenler", "filter_playlists": "Oynatma listelerinizi filtreleyin...", - "liked_tracks": "Beğenilen parçalar", + "liked_tracks": "Beğenilen Parçalar", "liked_tracks_description": "Beğendiğiniz tüm parçalar", - "create_playlist": "Oynatma listesi oluştur", - "create_a_playlist": "Bir oynatma listesi oluştur", + "create_playlist": "Oynatma Listesi Oluştur", + "create_a_playlist": "Bir oynatma listesi oluşturun", "update_playlist": "Oynatma listesini güncelle", "create": "Oluştur", "cancel": "İptal", "update": "Güncelle", - "playlist_name": "Oynatma listesi adı", + "playlist_name": "Oynatma Listesi Adı", "name_of_playlist": "Oynatma listesinin adı", "description": "Açıklama", "public": "Halka açık", @@ -39,16 +39,16 @@ "none": "Yok", "sort_a_z": "A - Z'ye göre sırala", "sort_z_a": "Z - A'ya göre sırala", - "sort_artist": "Sanatçıya göre sırala", - "sort_album": "Albüme göre sırala", - "sort_duration": "Süreye göre sırala", - "sort_tracks": "Parçaları sırala", - "currently_downloading": "Şu anda indirilenler ({tracks_length})", - "cancel_all": "Tümünü iptal et", - "filter_artist": "Sanatçıları filtreleyin...", + "sort_artist": "Sanatçıya Göre Sırala", + "sort_album": "Albüme Göre Sırala", + "sort_duration": "Süreye Göre Sırala", + "sort_tracks": "Parçaları Sırala", + "currently_downloading": "Şu An İndirilenler ({tracks_length})", + "cancel_all": "Tümünü İptal Et", + "filter_artist": "Sanatçıları filtrele...", "followers": "{followers} Takipçiler", "add_artist_to_blacklist": "Sanatçıyı kara listeye ekle", - "top_tracks": "En iyi parçalar", + "top_tracks": "En İyi Parçalar", "fans_also_like": "Hayranlar ayrıca şunları da beğendi", "loading": "Yükleniyor...", "artist": "Sanatçı", @@ -57,7 +57,7 @@ "follow": "Takip et", "artist_url_copied": "Sanatçı bağlantısı panoya kopyalandı", "added_to_queue": "Kuyruğa {tracks} parçası eklendi", - "filter_albums": "Albümleri filtreleyin...", + "filter_albums": "Albümleri filtrele...", "synced": "Senkronize edildi", "plain": "Sade", "shuffle": "Karıştır", @@ -68,19 +68,19 @@ "time": "Zaman", "more_actions": "Daha fazla eylem", "download_count": "İndir ({count})", - "add_count_to_playlist": "Oynatma Listesine ekle ({count})", - "add_count_to_queue": "Kuyruğa ekle ({count})", - "play_count_next": "Sonrakini oynat ({count})", + "add_count_to_playlist": "Oynatma Listesine ({count}) ekle", + "add_count_to_queue": "Kuyruğa ({count}) ekle", + "play_count_next": "({count}) sonrakini oynat", "album": "Albüm", "copied_to_clipboard": "{data} panoya kopyalandı", - "add_to_following_playlists": "{track} parçasını aşağıdaki oynatma listelerine ekle", + "add_to_following_playlists": "{track} parçasını aşağıdaki Oynatma Listelerine ekle", "add": "Ekle", "added_track_to_queue": "{track} kuyruğa eklendi", "add_to_queue": "Kuyruğa ekle", "track_will_play_next": "{track} bir sonraki çalacak", "play_next": "Sonrakini oynat", - "removed_track_from_queue": "{track} kuyruktan kaldırıldı", - "remove_from_queue": "Kuyruktan kaldır", + "removed_track_from_queue": "{track} sıradan kaldırıldı", + "remove_from_queue": "Sıradan kaldır", "remove_from_favorites": "Favorilerden kaldır", "save_as_favorite": "Favori olarak kaydet", "add_to_playlist": "Oynatma listesine ekle", @@ -88,7 +88,7 @@ "add_to_blacklist": "Kara listeye ekle", "remove_from_blacklist": "Kara listeden kaldır", "share": "Paylaş", - "mini_player": "Mini oynatıcı", + "mini_player": "Mini Oynatıcı", "slide_to_seek": "İleri veya geri arama yapmak için kaydırın", "shuffle_playlist": "Oynatma listesini karıştır", "unshuffle_playlist": "Oynatma listesinin karışıklığını kaldır", @@ -98,27 +98,27 @@ "resume_playback": "Oynatmayı sürdür", "loop_track": "Döngü parçası", "repeat_playlist": "Oynatma listesini tekrarla", - "queue": "Kuyruk", - "alternative_track_sources": "Alternatif parça kaynakları", + "queue": "Sıra", + "alternative_track_sources": "Alternatif yol kaynakları", "download_track": "Parçayı indir", - "tracks_in_queue": "{tracks} parça kuyrukta", + "tracks_in_queue": "{tracks} parça sırada", "clear_all": "Tümünü temizle", "show_hide_ui_on_hover": "Fareyle üzerine gelindiğinde kullanıcı arayüzünü göster/gizle", "always_on_top": "Her zaman üstte", "exit_mini_player": "Mini oynatıcıdan çık", "download_location": "İndirme konumu", "account": "Hesap", - "login_with_spotify": "Spotify hesabı ile giriş yap", + "login_with_spotify": "Spotify hesabınızla giriş yapın", "connect_with_spotify": "Spotify ile bağlan", - "logout": "Çıkış yap", - "logout_of_this_account": "Hesaptan çıkış yap", - "language_region": "Dil ve bölge", - "language": "Tercih edilen dil", - "system_default": "Sistem varsayılanı", - "market_place_region": "Tercih edilen bölge", - "recommendation_country": "Tavsiye edilen ülke", + "logout": "Çıkış Yap", + "logout_of_this_account": "Bu hesaptan çıkış yap", + "language_region": "Dil ve Bölge", + "language": "Dil", + "system_default": "Sistem Varsayılanı", + "market_place_region": "Pazaryeri Bölgesi", + "recommendation_country": "Tavsiye Edilen Ülke", "appearance": "Görünüm", - "layout_mode": "Düzen modu", + "layout_mode": "Düzen Modu", "override_layout_settings": "Duyarlı düzen modu ayarlarını geçersiz kıl", "adaptive": "Uyarlanabilir", "compact": "Sıkıştırılmış", @@ -127,35 +127,35 @@ "dark": "Koyu", "light": "Açık", "system": "Sistem", - "accent_color": "Vurgu rengi", + "accent_color": "Vurgu Rengi", "sync_album_color": "Albüm rengini senkronize et", "sync_album_color_description": "Vurgu rengi olarak albüm resminin baskın rengini kullanır", "playback": "Oynatma", - "audio_quality": "Ses kalitesi", + "audio_quality": "Ses Kalitesi", "high": "Yüksek", "low": "Düşük", - "pre_download_play": "Önceden indir ve oynat", - "pre_download_play_description": "Ses akışı yerine baytları indir ve oynat (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)", - "skip_non_music": "Müzik olmayan bölümleri atlat (SponsorBlock)", + "pre_download_play": "Ön yükleme ve oynatma", + "pre_download_play_description": "Ses akışı yerine baytları indirin ve oynatın (Daha yüksek bant genişliğine sahip kullanıcılar için önerilir)", + "skip_non_music": "Müzik olmayan bölümleri atla (SponsorBlock)", "blacklist_description": "Kara listeye alınan parçalar ve sanatçılar", "wait_for_download_to_finish": "Lütfen mevcut indirme işleminin tamamlanmasını bekleyin", "desktop": "Masaüstü", - "close_behavior": "Kapatma davranışı", + "close_behavior": "Kapatma Davranışı", "close": "Kapat", "minimize_to_tray": "Tepsiye küçült", "show_tray_icon": "Sistem tepsisi simgesini göster", "about": "Hakkında", "u_love_spotube": "Spotube'u sevdiğinizi biliyoruz", "check_for_updates": "Güncellemeleri kontrol et", - "about_spotube": "Spotube hakkında", + "about_spotube": "Spotube Hakkında", "blacklist": "Kara liste", "please_sponsor": "Sponsor Ol/Bağış Yap", - "spotube_description": "Spotube, hafif, platformlar arası uyumlu ve herkes için ücretsiz bir Spotify istemcisidir.", + "spotube_description": "Spotube, hafif, platformlar arası, herkes için ücretsiz bir spotify istemcisidir", "version": "Sürüm", - "build_number": "Derleme numarası", - "founder": "Geliştirici", + "build_number": "Derleme Numarası", + "founder": "Kurucu", "repository": "Depo", - "bug_issues": "Hata + Sorunlar", + "bug_issues": "Hata+Sorunlar", "made_with": "❤️ ile Bangladeş'te yapıldı", "kingkor_roy_tirtho": "Kingkor Roy Tirtho", "copyright": "© 2021-{current_year} Kingkor Roy Tirtho", @@ -163,31 +163,31 @@ "add_spotify_credentials": "Başlamak için spotify kimlik bilgilerinizi ekleyin", "credentials_will_not_be_shared_disclaimer": "Endişelenmeyin, kimlik bilgilerinizden hiçbiri toplanmayacak veya kimseyle paylaşılmayacak", "know_how_to_login": "Bunu nasıl yapacağınızı bilmiyor musunuz?", - "follow_step_by_step_guide": "Adım adım kılavuzu takip edin", - "spotify_cookie": "Spotify {name} çerezi", - "cookie_name_cookie": "{name} çerezi", + "follow_step_by_step_guide": "Adım Adım kılavuzu takip edin", + "spotify_cookie": "Spotify {name} Çerezi", + "cookie_name_cookie": "{name} Çerezi", "fill_in_all_fields": "Lütfen tüm alanları doldurun", - "submit": "Başvur", + "submit": "Gönder", "exit": "Çık", "previous": "Önceki", "next": "Sonraki", "done": "Bitti", "step_1": "1. Adım", "first_go_to": "İlk olarak şuraya gidin:", - "login_if_not_logged_in": "ve oturum açmadıysanız Oturum açın/Kaydolun", + "login_if_not_logged_in": "ve oturum açmadıysanız Oturum Açın/Kaydolun", "step_2": "2. Adım", - "step_2_steps": "1. Oturum açtıktan sonra, tarayıcı geliştirme araçlarını açmak için F12'ye veya fareye sağ tıklayın > İncele'ye basın.\n2. Daha sonra \"Uygulama\" sekmesine (Chrome, Edge, Brave vb..) veya \"Depolama\" sekmesine (Firefox, Palemoon vb..) gidin\n3. \"Çerezler\" bölümüne, ardından \"https://accounts.spotify.com\" alt bölümüne gidin", + "step_2_steps": "1. Giriş yaptıktan sonra, Tarayıcı geliştirme araçlarını açmak için F12 veya Fareye Sağ Tıklayın > İncele'ye basın.\n2. Ardından \"Uygulama\" Sekmesine (Chrome, Edge, Brave vb.) veya \"Depolama\" Sekmesine (Firefox, Palemoon vb.) gidin.\n3. \"Çerezler\" bölümüne ve ardından \"https://accounts.spotify.com\" alt bölümüne gidin", "step_3": "3. Adım", "step_3_steps": "\"sp_dc\" Çerezinin değerini kopyalayın", "success_emoji": "Başarılı🥳", - "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Tebrik ederim!", + "success_message": "Artık Spotify hesabınızla başarıyla giriş yaptınız. Aferin, dostum!", "step_4": "4. Adım", "step_4_steps": "Kopyalanan \"sp_dc\" değerini yapıştırın", "something_went_wrong": "Bir hata oluştu", - "piped_instance": "Piped sunucu örneği", + "piped_instance": "Piped Sunucu Örneği", "piped_description": "Parça eşleştirme için kullanılacak Piped sunucu örneği", "piped_warning": "Bazıları iyi çalışmayabilir. Yani riski size ait olmak üzere kullanın", - "generate_playlist": "Oynatma listesi oluştur", + "generate_playlist": "Oynatma Listesi Oluştur", "track_exists": "{track} parçası zaten var", "replace_downloaded_tracks": "İndirilen tüm parçaları değiştir", "skip_download_tracks": "İndirilen tüm parçaları indirmeyi atla", @@ -195,8 +195,8 @@ "replace": "Değiştir", "skip": "Atla", "select_up_to_count_type": "En fazla {count} {type} seçin", - "select_genres": "Türleri seç", - "add_genres": "Tür ekle", + "select_genres": "Türleri Seç", + "add_genres": "Tür Ekle", "country": "Ülke", "number_of_tracks_generate": "Oluşturulacak parça sayısı", "acousticness": "Akustiklik", @@ -212,7 +212,7 @@ "duration": "Süre (sn)", "tempo": "Tempo (BPM)", "mode": "Mod", - "time_signature": "Zaman imzası", + "time_signature": "Zaman İmzası", "short": "Kısa", "medium": "Orta", "long": "Uzun", @@ -220,29 +220,29 @@ "max": "Maks", "target": "Hedef", "moderate": "Orta", - "deselect_all": "Tüm seçimleri kaldır", - "select_all": "Tümünü seç", + "deselect_all": "Tüm Seçimleri Kaldır", + "select_all": "Tümünü Seç", "are_you_sure": "Emin misiniz?", "generating_playlist": "Özel oynatma listeniz oluşturuluyor...", "selected_count_tracks": "{count} parça seçildi", - "download_warning": "Tüm şarkıları toplu olarak indiriyorsanız, açıkça müzik korsanlığı yapıyorsunuz ve müzik dünyasının yaratıcı topluluğuna zarar veriyorsunuz demektir. Umuyorum bunun farkındasınızdır. Her zaman, sanatçıların emeğine saygı göstermeyi ve desteklemeyi deneyin.", - "download_ip_ban_warning": "Ayrıca, normalden fazla indirme istekleri nedeniyle YouTube'da IP'niz engellenebilir. IP engeli, en az 2-3 ay boyunca YouTube'u (hatta oturum açmış olsanız bile) o IP cihazından kullanamayacağınız anlamına gelir. Ve eğer böyle bir durum yaşanırsa, Spotube bundan hiçbir sorumluluk kabul etmez.", + "download_warning": "Tüm Parçaları toplu olarak indirirseniz, açıkça Müzik korsanlığı yapıyor ve Müziğin yaratıcı toplumuna zarar veriyorsunuz demektir. Umarım bunun farkındasınızdır. Her zaman, Sanatçının sıkı çalışmasına saygı duymaya ve desteklemeye çalışın", + "download_ip_ban_warning": "Bu arada, IP'niz normalden daha fazla indirme isteği nedeniyle YouTube'da engellenebilir. IP engelleme, YouTube'u (oturum açmış olsanız bile) o IP cihazından en az 2 -3 ay kullanamayacağınız anlamına gelir. Ve eğer böyle bir şey olursa Spotube'un hiçbir sorumluluğu yok", "by_clicking_accept_terms": "\"Kabul et\" e tıklayarak aşağıdaki şartları kabul etmiş olursunuz:", - "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben fakir biriyim.", + "download_agreement_1": "Müzik korsanlığı yaptığımı biliyorum. Ben kötüyüm", "download_agreement_2": "Sanatçıyı elimden geldiğince destekleyeceğim ve bunu sadece sanatını satın alacak param olmadığı için yapıyorum", - "download_agreement_3": "YouTube'da IP'min engellenebileceğinin tamamen farkındayım ve mevcut eylemlerimden kaynaklanan herhangi bir kaza için Spotube'u veya sahiplerini/katkıda bulunanları sorumlu tutmuyorum.", + "download_agreement_3": "IP adresimin YouTube'da engellenebileceğinin tamamen farkındayım ve mevcut işlemimden kaynaklanan herhangi bir kazadan Spotube'u veya sahiplerini/katkıda bulunanlarını sorumlu tutmuyorum", "decline": "Reddet", "accept": "Kabul et", "details": "Detaylar", "youtube": "YouTube", "channel": "Kanal", - "likes": "Beğenenler", + "likes": "Beğeniler", "dislikes": "Beğenmeyenler", "views": "İzlenmeler", "streamUrl": "Akış bağlantısı", "stop": "Durdur", - "sort_newest": "En yeni eklenene göre sırala.", - "sort_oldest": "En eski eklenene göre sırala", + "sort_newest": "En yeniye göre sırala", + "sort_oldest": "Eklenen en eskiye göre sırala", "sleep_timer": "Uyku Zamanlayıcısı", "mins": "{minutes} Dakika", "hours": "{hours} Saatler", @@ -251,11 +251,11 @@ "logs": "Günlükler", "developers": "Geliştiriciler", "not_logged_in": "Giriş yapmadınız", - "search_mode": "Arama modu", - "audio_source": "Ses kaynağı", + "search_mode": "Arama Modu", + "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 depolamaya geri dönecektir\nLinux kullanıyorsanız, lütfen gnome-keyring, kde-wallet, keepassxc vb. herhangi bir gizli servisin yüklü olduğundan emin olun.", + "encryption_failed_warning": "Spotube, verilerinizi güvenli bir şekilde saklamak için şifreleme kullanır. Ama başaramadı. Bu yüzden güvensiz depolamaya geri dönecek\nLinux kullanıyorsanız, lütfen herhangi bir gizli servisin (gnome - anahtarlık, kde - cüzdan, keepassxc vb.) yüklü olduğundan emin olun", "querying_info": "Bilgi sorgulanıyor...", "piped_api_down": "Piped API kapalı", "piped_down_error_instructions": "Piped örneği {pipedInstance} şu anda kapalı\n\nÖrneği değiştirin veya 'API türünü' resmi YouTube API'si olarak değiştirin\n\nDeğişiklikten sonra uygulamayı yeniden başlattığınızdan emin olun", @@ -263,8 +263,8 @@ "connection_restored": "İnternet bağlantınız geri yüklendi", "use_system_title_bar": "Sistem başlık çubuğunu kullan", "crunching_results": "Sonuçlar...", - "search_to_get_results": "Sonuç almak için arayın", - "use_amoled_mode": "AMOLED modu kullan", + "search_to_get_results": "Sonuç almak için ara", + "use_amoled_mode": "AMOLED Modunu Kullan", "pitch_dark_theme": "Zifiri karanlık koyu tema", "normalize_audio": "Sesi normalleştir", "change_cover": "Kapağı değiştir", @@ -277,53 +277,48 @@ "disconnect_lastfm": "Last.fm bağlantısını kes", "disconnect": "Bağlantıyı kes", "username": "Kullanıcı adı", - "password": "Şifre", - "login": "Giriş yap", + "password": "Parola", + "login": "Giriş", "login_with_your_lastfm": "Last.fm hesabınızla giriş yapın", "scrobble_to_lastfm": "Last.fm için Scrobble", - "go_to_album": "Albüme git", - "discord_rich_presence": "Discord zengin varlığı", - "browse_all": "Tümüne göz at", - "genres": "Müzik türleri", - "explore_genres": "Türleri keşfet", + "go_to_album": "Albüme Git", + "discord_rich_presence": "Discord Zengin Varlığı", + "browse_all": "Tümüne Göz At", + "genres": "Müzik Türleri", + "explore_genres": "Türleri Keşfet", "friends": "Arkadaşlar", "no_lyrics_available": "Üzgünüz, bu parçanın sözleri bulunamıyor", - "start_a_radio": "Radyo başlat", + "start_a_radio": "Radyo Başlat", "how_to_start_radio": "Radyoyu nasıl başlatmak istersiniz?", "replace_queue_question": "Mevcut kuyruğu değiştirmek mi yoksa eklemek mi istersiniz?", - "endless_playback": "Sonsuz olarak oynat", - "delete_playlist": "Oynatma listesini sil", + "endless_playback": "Sonsuz Olarak Oynat", + "delete_playlist": "Oynatma Listesini Sil", "delete_playlist_confirmation": "Bu oynatma listesini silmek istediğinizden emin misiniz?", - "local_tracks": "Yerel parçalar", - "song_link": "Şarkı bağlantısı", + "local_tracks": "Yerel Parçalar", + "song_link": "Şarkı Bağlantısı", "skip_this_nonsense": "Bu saçmalığı atla", - "freedom_of_music": "“Müzik özgürlüğü”", - "freedom_of_music_palm": "“Müzik özgürlüğü avucunuzun içinde”", + "freedom_of_music": "“Müzik Özgürlüğü”", + "freedom_of_music_palm": "“Müzik Özgürlüğü avucunuzun içinde”", "get_started": "Haydi başlayalım", "youtube_source_description": "Tavsiye edilir ve en iyi şekilde çalışır.", - "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı, ama çok daha özgür.", + "piped_source_description": "Özgür hissediyor musunuz? YouTube ile aynı ama çok daha fazla ücretsiz.", "jiosaavn_source_description": "Güney Asya bölgesi için en iyisi.", - "highest_quality": "En yüksek kalite: {quality}", - "select_audio_source": "Ses kaynağını seçin", - "endless_playback_description": "Yeni şarkıları otomatik olarak\nkuyruğun sonuna ekle", + "highest_quality": "En Yüksek Kalite: {quality}", + "select_audio_source": "Ses Kaynağını Seç", + "endless_playback_description": "Yeni şarkıları otomatik olarak \nkuyruğun sonuna ekle", "choose_your_region": "Bölgenizi seçin", - "choose_your_region_description": "Bu, Spotube'un konumunuza uygun içerikleri göstermesine yardımcı olacaktır.", + "choose_your_region_description": "Bu, Spotube'un size doğru içeriği göstermesine yardımcı olacaktır\nkonumunuz için.", "choose_your_language": "Dilinizi seçin", - "help_project_grow": "Bu projenin büyümesine yardımcı olun", + "help_project_grow": "Bu projenin büyümesine yardımcı ol", "help_project_grow_description": "Spotube açık kaynaklı bir projedir. Projeye katkıda bulunarak, hataları bildirerek veya yeni özellikler önererek bu projenin büyümesine yardımcı olabilirsiniz.", - "contribute_on_github": "GitHub'da katkıda bulun", - "donate_on_open_collective": "Open Collective'de bağış yap", - "browse_anonymously": "Anonim olarak giriş yap", - "enable_connect": "Bağlanmayı etkinleştir", + "contribute_on_github": "GitHub'a katkıda bulunun", + "donate_on_open_collective": "Open Collective'e bağış yap", + "browse_anonymously": "Anonim Olarak Göz at", + "enable_connect": "Bağlantıyı Etkinleştir", "enable_connect_description": "Spotube'u diğer cihazlardan kontrol edin", "devices": "Cihazlar", "select": "Seç", "connect_client_alert": "{client} tarafından kontrol ediliyorsun.", - "this_device": "Bu cihaz", - "remote": "Yönet", - "local_library": "Yerel kütüphane", - "add_library_location": "Kütüphaneye ekle", - "remove_library_location": "Kütüphaneden çıkar", - "local_tab": "Yerel", - "stats": "İstatistikler" + "this_device": "Bu Cihaz", + "remote": "Yönet" } \ No newline at end of file diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index ebdc4b61..ef3685fa 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -7,7 +7,7 @@ /// TexturedPolak@github => Polish /// yuri-val@github => Ukrainian /// energywave@github, ncvescera@github, OpenCode@github => Italian -/// mikropsoft@github => Turkish +/// mdksec@github, mikropsoft@github => Turkish /// Stephan-P@github, SecularSteve@github => Dutch /// doannc2212@github => Vietnamese /// sappho192@github => Korean @@ -28,14 +28,11 @@ class L10n { const Locale('de', 'GE'), const Locale('es', 'ES'), const Locale('fa', 'IR'), - const Locale('fi', 'FI'), const Locale('fr', 'FR'), const Locale('ne', 'NP'), const Locale('hi', 'IN'), - const Locale('id', 'ID'), const Locale('it', 'IT'), const Locale('ja', 'JP'), - const Locale('ka', 'GE'), const Locale('ko', 'KR'), const Locale('nl', 'NL'), const Locale('pl', 'PL'), @@ -46,6 +43,5 @@ class L10n { const Locale('tr', 'TR'), const Locale('zh', 'CN'), const Locale('vi', 'VN'), - const Locale('eu', 'ES'), ]; } diff --git a/lib/main.dart b/lib/main.dart index 1693d9d8..0bb72932 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,17 @@ import 'package:catcher_2/catcher_2.dart'; import 'package:dart_discord_rpc/dart_discord_rpc.dart'; +import 'package:device_preview/device_preview.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:local_notifier/local_notifier.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'; @@ -17,7 +19,6 @@ 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/provider/tray_manager/tray_manager.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; @@ -30,17 +31,15 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:spotube/utils/platform.dart'; import 'package:system_theme/system_theme.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:spotube/hooks/configurators/use_init_sys_tray.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:timezone/data/latest.dart' as tz; -import 'package:window_manager/window_manager.dart'; Future main(List rawArgs) async { final arguments = await startCLI(rawArgs); @@ -56,12 +55,12 @@ Future main(List rawArgs) async { MediaKit.ensureInitialized(); // force High Refresh Rate on some Android devices (like One Plus) - if (kIsAndroid) { + if (DesktopTools.platform.isAndroid) { await FlutterDisplayMode.setHighRefreshRate(); } - if (kIsDesktop) { - await windowManager.setPreventClose(true); + if (DesktopTools.platform.isDesktop) { + await DesktopTools.window.setPreventClose(true); } await SystemTheme.accentColor.load(); @@ -70,7 +69,7 @@ Future main(List rawArgs) async { MetadataGod.initialize(); } - if (kIsWindows || kIsLinux) { + if (DesktopTools.platform.isWindows || DesktopTools.platform.isLinux) { DiscordRPC.initialize(); } @@ -102,10 +101,14 @@ Future main(List rawArgs) async { path: hiveCacheDir, ); - if (kIsDesktop) { - await localNotifier.setup(appName: "Spotube"); - await WindowManagerTools.initialize(); - } + await DesktopTools.ensureInitialized( + DesktopWindowOptions( + hideTitleBar: true, + title: "Spotube", + backgroundColor: Colors.transparent, + minimumSize: const Size(300, 700), + ), + ); Catcher2( enableLogger: arguments["verbose"], @@ -132,17 +135,46 @@ Future main(List rawArgs) async { ), runAppFunction: () { runApp( - const ProviderScope(child: Spotube()), + ProviderScope( + child: DevicePreview( + availableLocales: L10n.all, + enabled: false, + data: const DevicePreviewData( + isEnabled: false, + orientation: Orientation.portrait, + ), + builder: (context) { + return const Spotube(); + }, + ), + ), ); }, ); } -class Spotube extends HookConsumerWidget { +class Spotube extends StatefulHookConsumerWidget { const Spotube({super.key}); @override - Widget build(BuildContext context, ref) { + SpotubeState createState() => SpotubeState(); + + static SpotubeState of(BuildContext context) => + context.findAncestorStateOfType()!; +} + +class SpotubeState extends ConsumerState { + final logger = getLogger(Spotube); + SharedPreferences? localStorage; + + @override + void initState() { + super.initState(); + SharedPreferences.getInstance().then(((value) => localStorage = value)); + } + + @override + Widget build(BuildContext context) { final themeMode = ref.watch(userPreferencesProvider.select((s) => s.themeMode)); final accentMaterialColor = @@ -157,9 +189,9 @@ class Spotube extends HookConsumerWidget { ref.listen(playbackServerProvider, (_, __) {}); ref.listen(connectServerProvider, (_, __) {}); ref.listen(connectClientsProvider, (_, __) {}); - ref.listen(trayManagerProvider, (_, __) {}); useDisableBatteryOptimizations(); + useInitSysTray(ref); useDeepLinking(ref); useCloseBehavior(ref); useGetStoragePermissions(ref); @@ -177,7 +209,6 @@ class Spotube extends HookConsumerWidget { () => theme(paletteColor ?? accentMaterialColor, Brightness.light, false), [paletteColor, accentMaterialColor], ); - final darkTheme = useMemoized( () => theme( paletteColor ?? accentMaterialColor, @@ -200,8 +231,12 @@ class Spotube extends HookConsumerWidget { debugShowCheckedModeBanner: false, title: 'Spotube', builder: (context, child) { - if (kIsDesktop && !kIsMacOS) return DragToResizeArea(child: child!); - return child!; + return DevicePreview.appBuilder( + context, + DesktopTools.platform.isDesktop && !DesktopTools.platform.isMacOS + ? DragToResizeArea(child: child!) + : child, + ); }, themeMode: themeMode, theme: lightTheme, diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart index 28386050..efb37315 100644 --- a/lib/models/connect/connect.dart +++ b/lib/models/connect/connect.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; diff --git a/lib/models/connect/connect.freezed.dart b/lib/models/connect/connect.freezed.dart index 088cfbd1..dcbd783d 100644 --- a/lib/models/connect/connect.freezed.dart +++ b/lib/models/connect/connect.freezed.dart @@ -12,93 +12,20 @@ part of 'connect.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); WebSocketLoadEventData _$WebSocketLoadEventDataFromJson( Map json) { - switch (json['runtimeType']) { - case 'playlist': - return WebSocketLoadEventDataPlaylist.fromJson(json); - case 'album': - return WebSocketLoadEventDataAlbum.fromJson(json); - - default: - throw CheckedFromJsonException( - json, - 'runtimeType', - 'WebSocketLoadEventData', - 'Invalid union type "${json['runtimeType']}"!'); - } + return _WebSocketLoadEventData.fromJson(json); } /// @nodoc mixin _$WebSocketLoadEventData { @JsonKey(name: 'tracks', toJson: _tracksJson) List get tracks => throw _privateConstructorUsedError; - Object? get collection => throw _privateConstructorUsedError; + String? get collectionId => throw _privateConstructorUsedError; int? get initialIndex => throw _privateConstructorUsedError; - @optionalTypeArgs - TResult when({ - required TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - PlaylistSimple? collection, - int? initialIndex) - playlist, - required TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - AlbumSimple? collection, - int? initialIndex) - album, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - PlaylistSimple? collection, - int? initialIndex)? - playlist, - TResult? Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - AlbumSimple? collection, - int? initialIndex)? - album, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - PlaylistSimple? collection, - int? initialIndex)? - playlist, - TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - AlbumSimple? collection, - int? initialIndex)? - album, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult map({ - required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, - required TResult Function(WebSocketLoadEventDataAlbum value) album, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult? Function(WebSocketLoadEventDataAlbum value)? album, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeMap({ - TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult Function(WebSocketLoadEventDataAlbum value)? album, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) $WebSocketLoadEventDataCopyWith get copyWith => @@ -113,6 +40,7 @@ abstract class $WebSocketLoadEventDataCopyWith<$Res> { @useResult $Res call( {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, + String? collectionId, int? initialIndex}); } @@ -131,6 +59,7 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, @override $Res call({ Object? tracks = null, + Object? collectionId = freezed, Object? initialIndex = freezed, }) { return _then(_value.copyWith( @@ -138,6 +67,10 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, ? _value.tracks : tracks // ignore: cast_nullable_to_non_nullable as List, + collectionId: freezed == collectionId + ? _value.collectionId + : collectionId // ignore: cast_nullable_to_non_nullable + as String?, initialIndex: freezed == initialIndex ? _value.initialIndex : initialIndex // ignore: cast_nullable_to_non_nullable @@ -147,46 +80,46 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res, } /// @nodoc -abstract class _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> +abstract class _$$WebSocketLoadEventDataImplCopyWith<$Res> implements $WebSocketLoadEventDataCopyWith<$Res> { - factory _$$WebSocketLoadEventDataPlaylistImplCopyWith( - _$WebSocketLoadEventDataPlaylistImpl value, - $Res Function(_$WebSocketLoadEventDataPlaylistImpl) then) = - __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>; + factory _$$WebSocketLoadEventDataImplCopyWith( + _$WebSocketLoadEventDataImpl value, + $Res Function(_$WebSocketLoadEventDataImpl) then) = + __$$WebSocketLoadEventDataImplCopyWithImpl<$Res>; @override @useResult $Res call( {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - PlaylistSimple? collection, + String? collectionId, int? initialIndex}); } /// @nodoc -class __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res> +class __$$WebSocketLoadEventDataImplCopyWithImpl<$Res> extends _$WebSocketLoadEventDataCopyWithImpl<$Res, - _$WebSocketLoadEventDataPlaylistImpl> - implements _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res> { - __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl( - _$WebSocketLoadEventDataPlaylistImpl _value, - $Res Function(_$WebSocketLoadEventDataPlaylistImpl) _then) + _$WebSocketLoadEventDataImpl> + implements _$$WebSocketLoadEventDataImplCopyWith<$Res> { + __$$WebSocketLoadEventDataImplCopyWithImpl( + _$WebSocketLoadEventDataImpl _value, + $Res Function(_$WebSocketLoadEventDataImpl) _then) : super(_value, _then); @pragma('vm:prefer-inline') @override $Res call({ Object? tracks = null, - Object? collection = freezed, + Object? collectionId = freezed, Object? initialIndex = freezed, }) { - return _then(_$WebSocketLoadEventDataPlaylistImpl( + return _then(_$WebSocketLoadEventDataImpl( tracks: null == tracks ? _value._tracks : tracks // ignore: cast_nullable_to_non_nullable as List, - collection: freezed == collection - ? _value.collection - : collection // ignore: cast_nullable_to_non_nullable - as PlaylistSimple?, + collectionId: freezed == collectionId + ? _value.collectionId + : collectionId // ignore: cast_nullable_to_non_nullable + as String?, initialIndex: freezed == initialIndex ? _value.initialIndex : initialIndex // ignore: cast_nullable_to_non_nullable @@ -197,21 +130,16 @@ class __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res> /// @nodoc @JsonSerializable() -class _$WebSocketLoadEventDataPlaylistImpl - extends WebSocketLoadEventDataPlaylist { - _$WebSocketLoadEventDataPlaylistImpl( +class _$WebSocketLoadEventDataImpl implements _WebSocketLoadEventData { + _$WebSocketLoadEventDataImpl( {@JsonKey(name: 'tracks', toJson: _tracksJson) required final List tracks, - this.collection, - this.initialIndex, - final String? $type}) - : _tracks = tracks, - $type = $type ?? 'playlist', - super._(); + this.collectionId, + this.initialIndex}) + : _tracks = tracks; - factory _$WebSocketLoadEventDataPlaylistImpl.fromJson( - Map json) => - _$$WebSocketLoadEventDataPlaylistImplFromJson(json); + factory _$WebSocketLoadEventDataImpl.fromJson(Map json) => + _$$WebSocketLoadEventDataImplFromJson(json); final List _tracks; @override @@ -223,26 +151,23 @@ class _$WebSocketLoadEventDataPlaylistImpl } @override - final PlaylistSimple? collection; + final String? collectionId; @override final int? initialIndex; - @JsonKey(name: 'runtimeType') - final String $type; - @override String toString() { - return 'WebSocketLoadEventData.playlist(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)'; + return 'WebSocketLoadEventData(tracks: $tracks, collectionId: $collectionId, initialIndex: $initialIndex)'; } @override bool operator ==(Object other) { return identical(this, other) || (other.runtimeType == runtimeType && - other is _$WebSocketLoadEventDataPlaylistImpl && + other is _$WebSocketLoadEventDataImpl && const DeepCollectionEquality().equals(other._tracks, _tracks) && - (identical(other.collection, collection) || - other.collection == collection) && + (identical(other.collectionId, collectionId) || + other.collectionId == collectionId) && (identical(other.initialIndex, initialIndex) || other.initialIndex == initialIndex)); } @@ -250,361 +175,42 @@ class _$WebSocketLoadEventDataPlaylistImpl @JsonKey(ignore: true) @override int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(_tracks), collection, initialIndex); + const DeepCollectionEquality().hash(_tracks), collectionId, initialIndex); @JsonKey(ignore: true) @override @pragma('vm:prefer-inline') - _$$WebSocketLoadEventDataPlaylistImplCopyWith< - _$WebSocketLoadEventDataPlaylistImpl> - get copyWith => __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl< - _$WebSocketLoadEventDataPlaylistImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - PlaylistSimple? collection, - int? initialIndex) - playlist, - required TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - AlbumSimple? collection, - int? initialIndex) - album, - }) { - return playlist(tracks, collection, initialIndex); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - PlaylistSimple? collection, - int? initialIndex)? - playlist, - TResult? Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - AlbumSimple? collection, - int? initialIndex)? - album, - }) { - return playlist?.call(tracks, collection, initialIndex); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - PlaylistSimple? collection, - int? initialIndex)? - playlist, - TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - AlbumSimple? collection, - int? initialIndex)? - album, - required TResult orElse(), - }) { - if (playlist != null) { - return playlist(tracks, collection, initialIndex); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, - required TResult Function(WebSocketLoadEventDataAlbum value) album, - }) { - return playlist(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult? Function(WebSocketLoadEventDataAlbum value)? album, - }) { - return playlist?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult Function(WebSocketLoadEventDataAlbum value)? album, - required TResult orElse(), - }) { - if (playlist != null) { - return playlist(this); - } - return orElse(); - } + _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> + get copyWith => __$$WebSocketLoadEventDataImplCopyWithImpl< + _$WebSocketLoadEventDataImpl>(this, _$identity); @override Map toJson() { - return _$$WebSocketLoadEventDataPlaylistImplToJson( + return _$$WebSocketLoadEventDataImplToJson( this, ); } } -abstract class WebSocketLoadEventDataPlaylist extends WebSocketLoadEventData { - factory WebSocketLoadEventDataPlaylist( +abstract class _WebSocketLoadEventData implements WebSocketLoadEventData { + factory _WebSocketLoadEventData( {@JsonKey(name: 'tracks', toJson: _tracksJson) required final List tracks, - final PlaylistSimple? collection, - final int? initialIndex}) = _$WebSocketLoadEventDataPlaylistImpl; - WebSocketLoadEventDataPlaylist._() : super._(); + final String? collectionId, + final int? initialIndex}) = _$WebSocketLoadEventDataImpl; - factory WebSocketLoadEventDataPlaylist.fromJson(Map json) = - _$WebSocketLoadEventDataPlaylistImpl.fromJson; + factory _WebSocketLoadEventData.fromJson(Map json) = + _$WebSocketLoadEventDataImpl.fromJson; @override @JsonKey(name: 'tracks', toJson: _tracksJson) List get tracks; @override - PlaylistSimple? get collection; + String? get collectionId; @override int? get initialIndex; @override @JsonKey(ignore: true) - _$$WebSocketLoadEventDataPlaylistImplCopyWith< - _$WebSocketLoadEventDataPlaylistImpl> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res> - implements $WebSocketLoadEventDataCopyWith<$Res> { - factory _$$WebSocketLoadEventDataAlbumImplCopyWith( - _$WebSocketLoadEventDataAlbumImpl value, - $Res Function(_$WebSocketLoadEventDataAlbumImpl) then) = - __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {@JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - AlbumSimple? collection, - int? initialIndex}); -} - -/// @nodoc -class __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res> - extends _$WebSocketLoadEventDataCopyWithImpl<$Res, - _$WebSocketLoadEventDataAlbumImpl> - implements _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res> { - __$$WebSocketLoadEventDataAlbumImplCopyWithImpl( - _$WebSocketLoadEventDataAlbumImpl _value, - $Res Function(_$WebSocketLoadEventDataAlbumImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? tracks = null, - Object? collection = freezed, - Object? initialIndex = freezed, - }) { - return _then(_$WebSocketLoadEventDataAlbumImpl( - tracks: null == tracks - ? _value._tracks - : tracks // ignore: cast_nullable_to_non_nullable - as List, - collection: freezed == collection - ? _value.collection - : collection // ignore: cast_nullable_to_non_nullable - as AlbumSimple?, - initialIndex: freezed == initialIndex - ? _value.initialIndex - : initialIndex // ignore: cast_nullable_to_non_nullable - as int?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum { - _$WebSocketLoadEventDataAlbumImpl( - {@JsonKey(name: 'tracks', toJson: _tracksJson) - required final List tracks, - this.collection, - this.initialIndex, - final String? $type}) - : _tracks = tracks, - $type = $type ?? 'album', - super._(); - - factory _$WebSocketLoadEventDataAlbumImpl.fromJson( - Map json) => - _$$WebSocketLoadEventDataAlbumImplFromJson(json); - - final List _tracks; - @override - @JsonKey(name: 'tracks', toJson: _tracksJson) - List get tracks { - if (_tracks is EqualUnmodifiableListView) return _tracks; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_tracks); - } - - @override - final AlbumSimple? collection; - @override - final int? initialIndex; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'WebSocketLoadEventData.album(tracks: $tracks, collection: $collection, initialIndex: $initialIndex)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$WebSocketLoadEventDataAlbumImpl && - const DeepCollectionEquality().equals(other._tracks, _tracks) && - (identical(other.collection, collection) || - other.collection == collection) && - (identical(other.initialIndex, initialIndex) || - other.initialIndex == initialIndex)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(_tracks), collection, initialIndex); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl> - get copyWith => __$$WebSocketLoadEventDataAlbumImplCopyWithImpl< - _$WebSocketLoadEventDataAlbumImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - PlaylistSimple? collection, - int? initialIndex) - playlist, - required TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - AlbumSimple? collection, - int? initialIndex) - album, - }) { - return album(tracks, collection, initialIndex); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - PlaylistSimple? collection, - int? initialIndex)? - playlist, - TResult? Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - AlbumSimple? collection, - int? initialIndex)? - album, - }) { - return album?.call(tracks, collection, initialIndex); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - PlaylistSimple? collection, - int? initialIndex)? - playlist, - TResult Function( - @JsonKey(name: 'tracks', toJson: _tracksJson) List tracks, - AlbumSimple? collection, - int? initialIndex)? - album, - required TResult orElse(), - }) { - if (album != null) { - return album(tracks, collection, initialIndex); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(WebSocketLoadEventDataPlaylist value) playlist, - required TResult Function(WebSocketLoadEventDataAlbum value) album, - }) { - return album(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult? Function(WebSocketLoadEventDataAlbum value)? album, - }) { - return album?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(WebSocketLoadEventDataPlaylist value)? playlist, - TResult Function(WebSocketLoadEventDataAlbum value)? album, - required TResult orElse(), - }) { - if (album != null) { - return album(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$WebSocketLoadEventDataAlbumImplToJson( - this, - ); - } -} - -abstract class WebSocketLoadEventDataAlbum extends WebSocketLoadEventData { - factory WebSocketLoadEventDataAlbum( - {@JsonKey(name: 'tracks', toJson: _tracksJson) - required final List tracks, - final AlbumSimple? collection, - final int? initialIndex}) = _$WebSocketLoadEventDataAlbumImpl; - WebSocketLoadEventDataAlbum._() : super._(); - - factory WebSocketLoadEventDataAlbum.fromJson(Map json) = - _$WebSocketLoadEventDataAlbumImpl.fromJson; - - @override - @JsonKey(name: 'tracks', toJson: _tracksJson) - List get tracks; - @override - AlbumSimple? get collection; - @override - int? get initialIndex; - @override - @JsonKey(ignore: true) - _$$WebSocketLoadEventDataAlbumImplCopyWith<_$WebSocketLoadEventDataAlbumImpl> + _$$WebSocketLoadEventDataImplCopyWith<_$WebSocketLoadEventDataImpl> get copyWith => throw _privateConstructorUsedError; } diff --git a/lib/models/connect/connect.g.dart b/lib/models/connect/connect.g.dart index f297024b..f636e035 100644 --- a/lib/models/connect/connect.g.dart +++ b/lib/models/connect/connect.g.dart @@ -6,48 +6,20 @@ part of 'connect.dart'; // JsonSerializableGenerator // ************************************************************************** -_$WebSocketLoadEventDataPlaylistImpl - _$$WebSocketLoadEventDataPlaylistImplFromJson(Map json) => - _$WebSocketLoadEventDataPlaylistImpl( - tracks: (json['tracks'] as List) - .map((e) => Track.fromJson(Map.from(e as Map))) - .toList(), - collection: json['collection'] == null - ? null - : PlaylistSimple.fromJson( - Map.from(json['collection'] as Map)), - initialIndex: json['initialIndex'] as int?, - $type: json['runtimeType'] as String?, - ); - -Map _$$WebSocketLoadEventDataPlaylistImplToJson( - _$WebSocketLoadEventDataPlaylistImpl instance) => - { - 'tracks': _tracksJson(instance.tracks), - 'collection': instance.collection?.toJson(), - 'initialIndex': instance.initialIndex, - 'runtimeType': instance.$type, - }; - -_$WebSocketLoadEventDataAlbumImpl _$$WebSocketLoadEventDataAlbumImplFromJson( - Map json) => - _$WebSocketLoadEventDataAlbumImpl( +_$WebSocketLoadEventDataImpl _$$WebSocketLoadEventDataImplFromJson( + Map json) => + _$WebSocketLoadEventDataImpl( tracks: (json['tracks'] as List) - .map((e) => Track.fromJson(Map.from(e as Map))) + .map((e) => Track.fromJson(e as Map)) .toList(), - collection: json['collection'] == null - ? null - : AlbumSimple.fromJson( - Map.from(json['collection'] as Map)), + collectionId: json['collectionId'] as String?, initialIndex: json['initialIndex'] as int?, - $type: json['runtimeType'] as String?, ); -Map _$$WebSocketLoadEventDataAlbumImplToJson( - _$WebSocketLoadEventDataAlbumImpl instance) => +Map _$$WebSocketLoadEventDataImplToJson( + _$WebSocketLoadEventDataImpl instance) => { 'tracks': _tracksJson(instance.tracks), - 'collection': instance.collection?.toJson(), + 'collectionId': instance.collectionId, 'initialIndex': instance.initialIndex, - 'runtimeType': instance.$type, }; diff --git a/lib/models/connect/load.dart b/lib/models/connect/load.dart index bf0e164d..d750cddd 100644 --- a/lib/models/connect/load.dart +++ b/lib/models/connect/load.dart @@ -6,27 +6,14 @@ List> _tracksJson(List tracks) { @freezed class WebSocketLoadEventData with _$WebSocketLoadEventData { - const WebSocketLoadEventData._(); - - factory WebSocketLoadEventData.playlist({ + factory WebSocketLoadEventData({ @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, - PlaylistSimple? collection, + String? collectionId, int? initialIndex, - }) = WebSocketLoadEventDataPlaylist; - - factory WebSocketLoadEventData.album({ - @JsonKey(name: 'tracks', toJson: _tracksJson) required List tracks, - AlbumSimple? collection, - int? initialIndex, - }) = WebSocketLoadEventDataAlbum; + }) = _WebSocketLoadEventData; factory WebSocketLoadEventData.fromJson(Map json) => _$WebSocketLoadEventDataFromJson(json); - - String? get collectionId => when( - playlist: (tracks, collection, _) => collection?.id, - album: (tracks, collection, _) => collection?.id, - ); } class WebSocketLoadEvent extends WebSocketEvent { diff --git a/lib/models/current_playlist.dart b/lib/models/current_playlist.dart index 7e55e393..53ea2799 100644 --- a/lib/models/current_playlist.dart +++ b/lib/models/current_playlist.dart @@ -1,5 +1,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; class CurrentPlaylist { diff --git a/lib/models/local_track.dart b/lib/models/local_track.dart index def3b64f..923f5f26 100644 --- a/lib/models/local_track.dart +++ b/lib/models/local_track.dart @@ -1,4 +1,5 @@ import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; class LocalTrack extends Track { final String path; @@ -34,10 +35,9 @@ class LocalTrack extends Track { ); } - @override Map toJson() { return { - ...super.toJson(), + ...TrackExtensions.trackToJson(this), 'path': path, }; } diff --git a/lib/models/logger.dart b/lib/models/logger.dart index 3236028d..4f687d09 100644 --- a/lib/models/logger.dart +++ b/lib/models/logger.dart @@ -27,7 +27,7 @@ Future getLogsPath() async { } final file = File(path.join(dir, ".spotube_logs")); if (!await file.exists()) { - await file.create(recursive: true); + await file.create(); } return file; } diff --git a/lib/models/source_match.g.dart b/lib/models/source_match.g.dart index 3b469694..11f34bf3 100644 --- a/lib/models/source_match.g.dart +++ b/lib/models/source_match.g.dart @@ -97,7 +97,7 @@ class SourceTypeAdapter extends TypeAdapter { // JsonSerializableGenerator // ************************************************************************** -SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( +SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( id: json['id'] as String, sourceId: json['sourceId'] as String, sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']), diff --git a/lib/models/spotify/home_feed.freezed.dart b/lib/models/spotify/home_feed.freezed.dart index c2bb2aba..97c4ffc7 100644 --- a/lib/models/spotify/home_feed.freezed.dart +++ b/lib/models/spotify/home_feed.freezed.dart @@ -12,7 +12,7 @@ part of 'home_feed.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); SpotifySectionPlaylist _$SpotifySectionPlaylistFromJson( Map json) { diff --git a/lib/models/spotify/home_feed.g.dart b/lib/models/spotify/home_feed.g.dart index fceb3db4..73a4f909 100644 --- a/lib/models/spotify/home_feed.g.dart +++ b/lib/models/spotify/home_feed.g.dart @@ -6,13 +6,14 @@ part of 'home_feed.dart'; // JsonSerializableGenerator // ************************************************************************** -_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson(Map json) => +_$SpotifySectionPlaylistImpl _$$SpotifySectionPlaylistImplFromJson( + Map json) => _$SpotifySectionPlaylistImpl( description: json['description'] as String, format: json['format'] as String, images: (json['images'] as List) - .map((e) => SpotifySectionItemImage.fromJson( - Map.from(e as Map))) + .map((e) => + SpotifySectionItemImage.fromJson(e as Map)) .toList(), name: json['name'] as String, owner: json['owner'] as String, @@ -24,19 +25,20 @@ Map _$$SpotifySectionPlaylistImplToJson( { 'description': instance.description, 'format': instance.format, - 'images': instance.images.map((e) => e.toJson()).toList(), + 'images': instance.images, 'name': instance.name, 'owner': instance.owner, 'uri': instance.uri, }; -_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson(Map json) => +_$SpotifySectionArtistImpl _$$SpotifySectionArtistImplFromJson( + Map json) => _$SpotifySectionArtistImpl( name: json['name'] as String, uri: json['uri'] as String, images: (json['images'] as List) - .map((e) => SpotifySectionItemImage.fromJson( - Map.from(e as Map))) + .map((e) => + SpotifySectionItemImage.fromJson(e as Map)) .toList(), ); @@ -45,18 +47,19 @@ Map _$$SpotifySectionArtistImplToJson( { 'name': instance.name, 'uri': instance.uri, - 'images': instance.images.map((e) => e.toJson()).toList(), + 'images': instance.images, }; -_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(Map json) => +_$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson( + Map json) => _$SpotifySectionAlbumImpl( artists: (json['artists'] as List) - .map((e) => SpotifySectionAlbumArtist.fromJson( - Map.from(e as Map))) + .map((e) => + SpotifySectionAlbumArtist.fromJson(e as Map)) .toList(), images: (json['images'] as List) - .map((e) => SpotifySectionItemImage.fromJson( - Map.from(e as Map))) + .map((e) => + SpotifySectionItemImage.fromJson(e as Map)) .toList(), name: json['name'] as String, uri: json['uri'] as String, @@ -65,14 +68,14 @@ _$SpotifySectionAlbumImpl _$$SpotifySectionAlbumImplFromJson(Map json) => Map _$$SpotifySectionAlbumImplToJson( _$SpotifySectionAlbumImpl instance) => { - 'artists': instance.artists.map((e) => e.toJson()).toList(), - 'images': instance.images.map((e) => e.toJson()).toList(), + 'artists': instance.artists, + 'images': instance.images, 'name': instance.name, 'uri': instance.uri, }; _$SpotifySectionAlbumArtistImpl _$$SpotifySectionAlbumArtistImplFromJson( - Map json) => + Map json) => _$SpotifySectionAlbumArtistImpl( name: json['name'] as String, uri: json['uri'] as String, @@ -86,7 +89,7 @@ Map _$$SpotifySectionAlbumArtistImplToJson( }; _$SpotifySectionItemImageImpl _$$SpotifySectionItemImageImplFromJson( - Map json) => + Map json) => _$SpotifySectionItemImageImpl( height: json['height'] as num?, url: json['url'] as String, @@ -102,40 +105,40 @@ Map _$$SpotifySectionItemImageImplToJson( }; _$SpotifyHomeFeedSectionItemImpl _$$SpotifyHomeFeedSectionItemImplFromJson( - Map json) => + Map json) => _$SpotifyHomeFeedSectionItemImpl( typename: json['typename'] as String, playlist: json['playlist'] == null ? null : SpotifySectionPlaylist.fromJson( - Map.from(json['playlist'] as Map)), + json['playlist'] as Map), artist: json['artist'] == null ? null : SpotifySectionArtist.fromJson( - Map.from(json['artist'] as Map)), + json['artist'] as Map), album: json['album'] == null ? null - : SpotifySectionAlbum.fromJson( - Map.from(json['album'] as Map)), + : SpotifySectionAlbum.fromJson(json['album'] as Map), ); Map _$$SpotifyHomeFeedSectionItemImplToJson( _$SpotifyHomeFeedSectionItemImpl instance) => { 'typename': instance.typename, - 'playlist': instance.playlist?.toJson(), - 'artist': instance.artist?.toJson(), - 'album': instance.album?.toJson(), + 'playlist': instance.playlist, + 'artist': instance.artist, + 'album': instance.album, }; -_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson(Map json) => +_$SpotifyHomeFeedSectionImpl _$$SpotifyHomeFeedSectionImplFromJson( + Map json) => _$SpotifyHomeFeedSectionImpl( typename: json['typename'] as String, title: json['title'] as String?, uri: json['uri'] as String, items: (json['items'] as List) - .map((e) => SpotifyHomeFeedSectionItem.fromJson( - Map.from(e as Map))) + .map((e) => + SpotifyHomeFeedSectionItem.fromJson(e as Map)) .toList(), ); @@ -145,15 +148,16 @@ Map _$$SpotifyHomeFeedSectionImplToJson( 'typename': instance.typename, 'title': instance.title, 'uri': instance.uri, - 'items': instance.items.map((e) => e.toJson()).toList(), + 'items': instance.items, }; -_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson(Map json) => +_$SpotifyHomeFeedImpl _$$SpotifyHomeFeedImplFromJson( + Map json) => _$SpotifyHomeFeedImpl( greeting: json['greeting'] as String, sections: (json['sections'] as List) - .map((e) => SpotifyHomeFeedSection.fromJson( - Map.from(e as Map))) + .map( + (e) => SpotifyHomeFeedSection.fromJson(e as Map)) .toList(), ); @@ -161,5 +165,5 @@ Map _$$SpotifyHomeFeedImplToJson( _$SpotifyHomeFeedImpl instance) => { 'greeting': instance.greeting, - 'sections': instance.sections.map((e) => e.toJson()).toList(), + 'sections': instance.sections, }; diff --git a/lib/models/spotify/recommendation_seeds.freezed.dart b/lib/models/spotify/recommendation_seeds.freezed.dart index adf4aab8..4cfcce12 100644 --- a/lib/models/spotify/recommendation_seeds.freezed.dart +++ b/lib/models/spotify/recommendation_seeds.freezed.dart @@ -12,7 +12,7 @@ part of 'recommendation_seeds.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); /// @nodoc mixin _$GeneratePlaylistProviderInput { diff --git a/lib/models/spotify/recommendation_seeds.g.dart b/lib/models/spotify/recommendation_seeds.g.dart index accb2ed1..bdfa3a07 100644 --- a/lib/models/spotify/recommendation_seeds.g.dart +++ b/lib/models/spotify/recommendation_seeds.g.dart @@ -6,7 +6,8 @@ part of 'recommendation_seeds.dart'; // JsonSerializableGenerator // ************************************************************************** -_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson(Map json) => +_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson( + Map json) => _$RecommendationSeedsImpl( acousticness: json['acousticness'] as num?, danceability: json['danceability'] as num?, diff --git a/lib/models/spotify_friends.g.dart b/lib/models/spotify_friends.g.dart index a1248429..4a32dd09 100644 --- a/lib/models/spotify_friends.g.dart +++ b/lib/models/spotify_friends.g.dart @@ -6,55 +6,60 @@ part of 'spotify_friends.dart'; // JsonSerializableGenerator // ************************************************************************** -SpotifyFriend _$SpotifyFriendFromJson(Map json) => SpotifyFriend( +SpotifyFriend _$SpotifyFriendFromJson(Map json) => + SpotifyFriend( uri: json['uri'] as String, name: json['name'] as String, imageUrl: json['imageUrl'] as String, ); -SpotifyActivityArtist _$SpotifyActivityArtistFromJson(Map json) => +SpotifyActivityArtist _$SpotifyActivityArtistFromJson( + Map json) => SpotifyActivityArtist( uri: json['uri'] as String, name: json['name'] as String, ); -SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson(Map json) => +SpotifyActivityAlbum _$SpotifyActivityAlbumFromJson( + Map json) => SpotifyActivityAlbum( uri: json['uri'] as String, name: json['name'] as String, ); -SpotifyActivityContext _$SpotifyActivityContextFromJson(Map json) => +SpotifyActivityContext _$SpotifyActivityContextFromJson( + Map json) => SpotifyActivityContext( uri: json['uri'] as String, name: json['name'] as String, index: json['index'] as num, ); -SpotifyActivityTrack _$SpotifyActivityTrackFromJson(Map json) => +SpotifyActivityTrack _$SpotifyActivityTrackFromJson( + Map json) => SpotifyActivityTrack( uri: json['uri'] as String, name: json['name'] as String, imageUrl: json['imageUrl'] as String, artist: SpotifyActivityArtist.fromJson( - Map.from(json['artist'] as Map)), - album: SpotifyActivityAlbum.fromJson( - Map.from(json['album'] as Map)), + json['artist'] as Map), + album: + SpotifyActivityAlbum.fromJson(json['album'] as Map), context: SpotifyActivityContext.fromJson( - Map.from(json['context'] as Map)), + json['context'] as Map), ); -SpotifyFriendActivity _$SpotifyFriendActivityFromJson(Map json) => +SpotifyFriendActivity _$SpotifyFriendActivityFromJson( + Map json) => SpotifyFriendActivity( - user: SpotifyFriend.fromJson( - Map.from(json['user'] as Map)), - track: SpotifyActivityTrack.fromJson( - Map.from(json['track'] as Map)), + user: SpotifyFriend.fromJson(json['user'] as Map), + track: + SpotifyActivityTrack.fromJson(json['track'] as Map), ); -SpotifyFriends _$SpotifyFriendsFromJson(Map json) => SpotifyFriends( +SpotifyFriends _$SpotifyFriendsFromJson(Map json) => + SpotifyFriends( friends: (json['friends'] as List) - .map((e) => SpotifyFriendActivity.fromJson( - Map.from(e as Map))) + .map((e) => SpotifyFriendActivity.fromJson(e as Map)) .toList(), ); diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index aea890a0..b24b69f4 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -8,8 +8,6 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class AlbumPage extends HookConsumerWidget { - static const name = "album"; - final AlbumSimple album; const AlbumPage({ super.key, @@ -24,7 +22,7 @@ class AlbumPage extends HookConsumerWidget { final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!)); return InheritedTrackView( - collection: album, + collectionId: album.id!, image: album.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 49890949..c3b04691 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -15,8 +15,6 @@ import 'package:spotube/pages/artist/section/top_tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPage extends HookConsumerWidget { - static const name = "artist"; - final String artistId; final logger = getLogger(ArtistPage); ArtistPage(this.artistId, {super.key}); diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 595ac510..9d407899 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -52,9 +52,8 @@ class ArtistPageTopTracks extends HookConsumerWidget { if (!isPlaylistPlaying) { await remotePlayback.load( - WebSocketLoadEventData.playlist( + WebSocketLoadEventData( tracks: tracks, - collection: null, initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), ), ); diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index c7cb493a..cbdb446e 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -5,13 +5,10 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/connect/local_devices.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/utils/service_utils.dart'; class ConnectPage extends HookConsumerWidget { - static const name = "connect"; - const ConnectPage({super.key}); @override @@ -68,9 +65,9 @@ class ConnectPage extends HookConsumerWidget { selected: selected, onTap: () { if (selected) { - ServiceUtils.pushNamed( + ServiceUtils.push( context, - ConnectControlPage.name, + "/connect/control", ); } else { connectClientsNotifier.resolveService(device); diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index 639a9dd9..b78f0ed3 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -13,7 +13,6 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/services/audio_player/loop_mode.dart'; @@ -47,8 +46,6 @@ class RemotePlayerQueue extends ConsumerWidget { } class ConnectControlPage extends HookConsumerWidget { - static const name = "connect_control"; - const ConnectControlPage({super.key}); @override @@ -128,13 +125,9 @@ class ConnectControlPage extends HookConsumerWidget { playlist.activeTrack?.name ?? "", style: textTheme.titleLarge!, onTap: () { - if (playlist.activeTrack == null) return; - ServiceUtils.pushNamed( + ServiceUtils.push( context, - TrackPage.name, - pathParameters: { - "id": playlist.activeTrack!.id!, - }, + "/track/${playlist.activeTrack?.id}", ); }, ), diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index c9367e05..9c061091 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -7,17 +7,15 @@ import 'package:spotube/components/desktop_login/login_form.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/pages/mobile_login/mobile_login.dart'; class DesktopLoginPage extends HookConsumerWidget { - static const name = WebViewLogin.name; const DesktopLoginPage({super.key}); @override Widget build(BuildContext context, ref) { final mediaQuery = MediaQuery.of(context); final theme = Theme.of(context); - final color = theme.colorScheme.surfaceContainerHighest.withOpacity(.3); + final color = theme.colorScheme.surfaceVariant.withOpacity(.3); return SafeArea( child: Scaffold( diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index dbec28dc..83b04af1 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -8,12 +8,10 @@ import 'package:spotube/components/desktop_login/login_form.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class LoginTutorial extends ConsumerWidget { - static const name = "login_tutorial"; const LoginTutorial({super.key}); @override @@ -55,7 +53,7 @@ class LoginTutorial extends ConsumerWidget { overrideDone: FilledButton( onPressed: authenticationNotifier.isLoggedIn ? () { - ServiceUtils.pushNamed(context, HomePage.name); + ServiceUtils.push(context, "/"); } : null, child: Center(child: Text(context.l10n.done)), diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart index fa205403..cbab03b9 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -12,8 +12,6 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/themes/theme.dart'; class GettingStarting extends HookConsumerWidget { - static const name = "getting_started"; - const GettingStarting({super.key}); @override diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index 7bccfe06..46823425 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -5,8 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -106,7 +104,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.go(HomePage.name); + context.go("/"); } }, ), @@ -122,7 +120,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { onPressed: () async { await KVStoreService.setDoneGettingStarted(true); if (context.mounted) { - context.pushNamed(WebViewLogin.name); + context.push("/login"); } }, ), diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart index d31b8256..c945251c 100644 --- a/lib/pages/home/feed/feed_section.dart +++ b/lib/pages/home/feed/feed_section.dart @@ -10,8 +10,6 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/views/home_section.dart'; class HomeFeedSectionPage extends HookConsumerWidget { - static const name = "home_feed_section"; - final String sectionUri; const HomeFeedSectionPage({super.key, required this.sectionUri}); diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index 531ea889..d80b4513 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -12,11 +12,9 @@ import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:collection/collection.dart'; -import 'package:spotube/utils/platform.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; class GenrePlaylistsPage extends HookConsumerWidget { - static const name = "genre_playlists"; - final Category category; const GenrePlaylistsPage({super.key, required this.category}); @@ -29,7 +27,7 @@ class GenrePlaylistsPage extends HookConsumerWidget { final scrollController = useScrollController(); return Scaffold( - appBar: kIsDesktop + appBar: DesktopTools.platform.isDesktop ? const PageWindowTitleBar( leading: BackButton(color: Colors.white), backgroundColor: Colors.transparent, @@ -55,12 +53,12 @@ class GenrePlaylistsPage extends HookConsumerWidget { controller: scrollController, slivers: [ SliverAppBar( - automaticallyImplyLeading: kIsMobile, + automaticallyImplyLeading: DesktopTools.platform.isMobile, expandedHeight: mediaQuery.mdAndDown ? 200 : 150, title: const Text(""), backgroundColor: Colors.transparent, flexibleSpace: FlexibleSpaceBar( - centerTitle: kIsDesktop, + centerTitle: DesktopTools.platform.isDesktop, title: Text( category.name!, style: Theme.of(context).textTheme.headlineMedium?.copyWith( diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index bb84fc16..291ce737 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -9,11 +9,9 @@ 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/pages/home/genres/genre_playlists.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class GenrePage extends HookConsumerWidget { - static const name = "genre"; const GenrePage({super.key}); @override @@ -49,13 +47,7 @@ class GenrePage extends HookConsumerWidget { return InkWell( borderRadius: BorderRadius.circular(8), onTap: () { - context.pushNamed( - GenrePlaylistsPage.name, - pathParameters: { - "categoryId": category.id!, - }, - extra: category, - ); + context.push("/genre/${category.id}", extra: category); }, child: Ink( padding: const EdgeInsets.all(8), diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index d4e2d94e..31f26bee 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,7 +3,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/connect/connect_device.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/feed.dart'; @@ -11,15 +10,15 @@ import 'package:spotube/components/home/sections/friends.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/home/sections/recent.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/pages/settings/settings.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class HomePage extends HookConsumerWidget { - static const name = "home"; const HomePage({super.key}); @override @@ -34,27 +33,39 @@ class HomePage extends HookConsumerWidget { body: CustomScrollView( controller: controller, slivers: [ - if (mediaQuery.smAndDown) + if (mediaQuery.mdAndDown) SliverAppBar( floating: true, title: Assets.spotubeLogoPng.image(height: 45), actions: [ const ConnectDeviceButton(), const Gap(10), - IconButton( - icon: const Icon(SpotubeIcons.settings, size: 20), - onPressed: () { - ServiceUtils.pushNamed(context, SettingsPage.name); - }, - ), + Consumer(builder: (context, ref, _) { + final me = ref.watch(meProvider); + final meData = me.asData?.value; + + return IconButton( + icon: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + (meData?.images).asUrlString( + placeholder: ImagePlaceholder.artist, + ), + ), + ), + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + ), + onPressed: () { + ServiceUtils.push(context, "/profile"); + }, + ); + }), const Gap(10), ], ) else if (kIsMacOS) const SliverGap(10), const HomeGenresSection(), - const SliverGap(10), - const SliverToBoxAdapter(child: HomeRecentlyPlayedSection()), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), const SliverToBoxAdapter(child: HomeNewReleasesSection()), diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index 2baeaad9..b6aeef2e 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -10,7 +10,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; class LastFMLoginPage extends HookConsumerWidget { - static const name = "lastfm_login"; const LastFMLoginPage({super.key}); @override diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index 5385f872..ccdb6a35 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -12,8 +12,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; class LibraryPage extends HookConsumerWidget { - static const name = "library"; - const LibraryPage({super.key}); @override Widget build(BuildContext context, ref) { @@ -29,7 +27,7 @@ class LibraryPage extends HookConsumerWidget { leading: ThemedButtonsTabBar( tabs: [ Tab(text: " ${context.l10n.playlists} "), - Tab(text: " ${context.l10n.local_tab} "), + Tab(text: " ${context.l10n.local_tracks} "), Tab( child: Badge( isLabelVisible: downloadingCount > 0, diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart deleted file mode 100644 index ac38e860..00000000 --- a/lib/pages/library/local_folder.dart +++ /dev/null @@ -1,240 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:fuzzywuzzy/fuzzywuzzy.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/collections/fake.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/user_local_tracks.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/page_window_title_bar.dart'; -import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; -import 'package:spotube/extensions/artist_simple.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class LocalLibraryPage extends HookConsumerWidget { - static const name = "local_library_page"; - - final String location; - final bool isDownloads; - const LocalLibraryPage(this.location, {super.key, this.isDownloads = false}); - - Future playLocalTracks( - WidgetRef ref, - List tracks, { - LocalTrack? currentTrack, - }) async { - final playlist = ref.read(proxyPlaylistProvider); - final playback = ref.read(proxyPlaylistProvider.notifier); - currentTrack ??= tracks.first; - final isPlaylistPlaying = playlist.containsTracks(tracks); - if (!isPlaylistPlaying) { - await playback.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playback.jumpToTrack(currentTrack); - } - } - - @override - Widget build(BuildContext context, ref) { - final sortBy = useState(SortBy.none); - final playlist = ref.watch(proxyPlaylistProvider); - final trackSnapshot = ref.watch(localTracksProvider); - final isPlaylistPlaying = playlist.containsTracks( - trackSnapshot.asData?.value.values.flattened.toList() ?? []); - - final searchController = useTextEditingController(); - useValueListenable(searchController); - final searchFocus = useFocusNode(); - final isFiltering = useState(false); - - final controller = useScrollController(); - - return SafeArea( - bottom: false, - child: Scaffold( - appBar: PageWindowTitleBar( - leading: const BackButton(), - centerTitle: true, - title: Text(isDownloads ? context.l10n.downloads : location), - backgroundColor: Colors.transparent, - ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const SizedBox(width: 5), - FilledButton( - onPressed: trackSnapshot.asData?.value != null - ? () async { - if (trackSnapshot.asData?.value.isNotEmpty == - true) { - if (!isPlaylistPlaying) { - await playLocalTracks( - ref, - trackSnapshot.asData!.value[location] ?? [], - ); - } - } - } - : null, - child: Row( - children: [ - Text(context.l10n.play), - Icon( - isPlaylistPlaying - ? SpotubeIcons.stop - : SpotubeIcons.play, - ) - ], - ), - ), - const Spacer(), - ExpandableSearchButton( - isFiltering: isFiltering.value, - onPressed: (value) => isFiltering.value = value, - searchFocus: searchFocus, - ), - const SizedBox(width: 10), - SortTracksDropdown( - value: sortBy.value, - onChanged: (value) { - sortBy.value = value; - }, - ), - const SizedBox(width: 5), - FilledButton( - child: const Icon(SpotubeIcons.refresh), - onPressed: () { - ref.invalidate(localTracksProvider); - }, - ) - ], - ), - ), - ExpandableSearchField( - searchController: searchController, - searchFocus: searchFocus, - isFiltering: isFiltering.value, - onChangeFiltering: (value) => isFiltering.value = value, - ), - trackSnapshot.when( - data: (tracks) { - final sortedTracks = useMemoized(() { - return ServiceUtils.sortTracks( - tracks[location] ?? [], sortBy.value); - }, [sortBy.value, tracks]); - - final filteredTracks = useMemoized(() { - if (searchController.text.isEmpty) { - return sortedTracks; - } - return sortedTracks - .map((e) => ( - weightedRatio( - "${e.name} - ${e.artists?.asString() ?? ""}", - searchController.text, - ), - e, - )) - .toList() - .sorted( - (a, b) => b.$1.compareTo(a.$1), - ) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList() - .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 { - ref.invalidate(localTracksProvider); - }, - child: InterScrollbar( - controller: controller, - 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( - playlist: playlist, - track: FakeData.track, - index: index, - ); - } - - final track = filteredTracks[index]; - return TrackTile( - index: index, - playlist: playlist, - track: track, - userPlaylist: false, - onTap: () async { - await playLocalTracks( - ref, - sortedTracks, - currentTrack: track, - ); - }, - ); - }, - ), - ), - ), - ), - ); - }, - loading: () => Expanded( - child: Skeletonizer( - enabled: true, - child: ListView.builder( - itemCount: 5, - itemBuilder: (context, index) => TrackTile( - track: FakeData.track, - index: index, - playlist: playlist, - ), - ), - ), - ), - error: (error, stackTrace) => - Text(error.toString() + stackTrace.toString()), - ) - ], - )), - ); - } -} diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 648e8528..5044090d 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -24,8 +24,6 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); class PlaylistGeneratorPage extends HookConsumerWidget { - static const name = "playlist_generator"; - const PlaylistGeneratorPage({super.key}); @override diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 5ee7ab36..01b73267 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -10,13 +10,10 @@ import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistGenerateResultPage extends HookConsumerWidget { - static const name = "playlist_generate_result"; - final GeneratePlaylistProviderInput state; const PlaylistGenerateResultPage({ @@ -126,11 +123,8 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ); if (playlist != null) { - router.goNamed( - PlaylistPage.name, - pathParameters: { - "id": playlist.id!, - }, + router.go( + '/playlist/${playlist.id}', extra: playlist, ); } diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 1d9b383a..ca13864a 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -23,8 +23,6 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class LyricsPage extends HookConsumerWidget { - static const name = "lyrics"; - final bool isModal; const LyricsPage({super.key, this.isModal = false}); @@ -100,7 +98,7 @@ class LyricsPage extends HookConsumerWidget { child: Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withOpacity(.4), + color: Theme.of(context).colorScheme.background.withOpacity(.4), borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index a026209c..1e4d4641 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; @@ -17,11 +18,8 @@ import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; class MiniLyricsPage extends HookConsumerWidget { - static const name = "mini_lyrics"; - final Size prevSize; const MiniLyricsPage({super.key, required this.prevSize}); @@ -38,11 +36,9 @@ class MiniLyricsPage extends HookConsumerWidget { final showLyrics = useState(true); useEffect(() { - if (kIsDesktop) { - WidgetsBinding.instance.addPostFrameCallback((_) async { - wasMaximized.value = await windowManager.isMaximized(); - }); - } + WidgetsBinding.instance.addPostFrameCallback((_) async { + wasMaximized.value = await DesktopTools.window.isMaximized(); + }); return null; }, []); @@ -107,7 +103,8 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.lyricsOff), style: ButtonStyle( foregroundColor: showLyrics.value - ? WidgetStateProperty.all(theme.colorScheme.primary) + ? MaterialStateProperty.all( + theme.colorScheme.primary) : null, ), onPressed: () async { @@ -115,13 +112,11 @@ class MiniLyricsPage extends HookConsumerWidget { areaActive.value = true; hoverMode.value = false; - if (kIsDesktop) { - await windowManager.setSize( - showLyrics.value - ? const Size(400, 500) - : const Size(400, 150), - ); - } + await DesktopTools.window.setSize( + showLyrics.value + ? const Size(400, 500) + : const Size(400, 150), + ); }, ), IconButton( @@ -131,7 +126,8 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.hoverOff), style: ButtonStyle( foregroundColor: hoverMode.value - ? WidgetStateProperty.all(theme.colorScheme.primary) + ? MaterialStateProperty.all( + theme.colorScheme.primary) : null, ), onPressed: () async { @@ -139,34 +135,33 @@ class MiniLyricsPage extends HookConsumerWidget { hoverMode.value = !hoverMode.value; }, ), - if (kIsDesktop) - FutureBuilder( - future: windowManager.isAlwaysOnTop(), - builder: (context, snapshot) { - return IconButton( - tooltip: context.l10n.always_on_top, - icon: Icon( - snapshot.data == true - ? SpotubeIcons.pinOn - : SpotubeIcons.pinOff, - ), - style: ButtonStyle( - foregroundColor: snapshot.data == true - ? WidgetStateProperty.all( - theme.colorScheme.primary) - : null, - ), - onPressed: snapshot.data == null - ? null - : () async { - await windowManager.setAlwaysOnTop( - snapshot.data == true ? false : true, - ); - update(); - }, - ); - }, - ), + FutureBuilder( + future: DesktopTools.window.isAlwaysOnTop(), + builder: (context, snapshot) { + return IconButton( + tooltip: context.l10n.always_on_top, + icon: Icon( + snapshot.data == true + ? SpotubeIcons.pinOn + : SpotubeIcons.pinOff, + ), + style: ButtonStyle( + foregroundColor: snapshot.data == true + ? MaterialStateProperty.all( + theme.colorScheme.primary) + : null, + ), + onPressed: snapshot.data == null + ? null + : () async { + await DesktopTools.window.setAlwaysOnTop( + snapshot.data == true ? false : true, + ); + update(); + }, + ); + }, + ), ], ), ), @@ -184,12 +179,12 @@ class MiniLyricsPage extends HookConsumerWidget { child: TabBarView( children: [ SyncedLyrics( - palette: PaletteColor(theme.colorScheme.surface, 0), + palette: PaletteColor(theme.colorScheme.background, 0), isModal: true, defaultTextZoom: 65, ), PlainLyrics( - palette: PaletteColor(theme.colorScheme.surface, 0), + palette: PaletteColor(theme.colorScheme.background, 0), isModal: true, defaultTextZoom: 65, ), @@ -248,20 +243,19 @@ class MiniLyricsPage extends HookConsumerWidget { tooltip: context.l10n.exit_mini_player, icon: const Icon(SpotubeIcons.maximize), onPressed: () async { - if (!kIsDesktop) return; - try { - await windowManager + await DesktopTools.window .setMinimumSize(const Size(300, 700)); - await windowManager.setAlwaysOnTop(false); + await DesktopTools.window.setAlwaysOnTop(false); if (wasMaximized.value) { - await windowManager.maximize(); + await DesktopTools.window.maximize(); } else { - await windowManager.setSize(prevSize); + await DesktopTools.window.setSize(prevSize); } - await windowManager.setAlignment(Alignment.center); + await DesktopTools.window + .setAlignment(Alignment.center); if (!kIsLinux) { - await windowManager.setHasShadow(true); + await DesktopTools.window.setHasShadow(true); } await Future.delayed( const Duration(milliseconds: 200)); diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 1f2df95a..0a1ff8b3 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -7,7 +7,6 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/utils/platform.dart'; class WebViewLogin extends HookConsumerWidget { - static const name = "login"; const WebViewLogin({super.key}); @override diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 44e99aea..72983518 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -3,12 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class LikedPlaylistPage extends HookConsumerWidget { - static const name = PlaylistPage.name; - final PlaylistSimple playlist; const LikedPlaylistPage({ super.key, @@ -21,7 +18,7 @@ class LikedPlaylistPage extends HookConsumerWidget { final tracks = likedTracks.asData?.value ?? []; return InheritedTrackView( - collection: playlist, + collectionId: playlist.id!, image: "assets/liked-tracks.jpg", pagination: PaginationProps( hasNextPage: false, diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 8fb22458..d9d224e0 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -10,8 +10,6 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistPage extends HookConsumerWidget { - static const name = "playlist"; - final PlaylistSimple playlist; const PlaylistPage({ super.key, @@ -31,7 +29,7 @@ class PlaylistPage extends HookConsumerWidget { final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!); return InheritedTrackView( - collection: playlist, + collectionId: playlist.id!, image: playlist.images.asUrlString( placeholder: ImagePlaceholder.collection, ), diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart index d77ae98d..52b69835 100644 --- a/lib/pages/profile/profile.dart +++ b/lib/pages/profile/profile.dart @@ -14,8 +14,6 @@ import 'package:spotube/provider/spotify/spotify.dart'; import 'package:url_launcher/url_launcher_string.dart'; class ProfilePage extends HookConsumerWidget { - static const name = "profile"; - const ProfilePage({super.key}); @override diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 258ecf3c..56ea43a6 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -14,14 +15,19 @@ import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/components/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; -import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/hooks/configurators/use_update_checker.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/service_utils.dart'; + +const rootPaths = { + "/": 0, + "/search": 1, + "/library": 2, + "/lyrics": 3, +}; class RootApp extends HookConsumerWidget { final Widget child; @@ -32,15 +38,15 @@ class RootApp extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final isMounted = useIsMounted(); final showingDialogCompleter = useRef(Completer()..complete()); final downloader = ref.watch(downloadManagerProvider); final scaffoldMessenger = ScaffoldMessenger.of(context); final theme = Theme.of(context); + final location = GoRouterState.of(context).matchedLocation; useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { - ServiceUtils.checkForUpdates(context, ref); - final sharedPreferences = await SharedPreferences.getInstance(); if (sharedPreferences.getBool(kIsUsingEncryption) == false && @@ -123,7 +129,7 @@ class RootApp extends HookConsumerWidget { useEffect(() { downloader.onFileExists = (track) async { - if (!context.mounted) return false; + if (!isMounted()) return false; if (!showingDialogCompleter.value.isCompleted) { await showingDialogCompleter.value.future; @@ -155,6 +161,7 @@ class RootApp extends HookConsumerWidget { }, [downloader]); // checks for latest version of the application + useUpdateChecker(ref); useEndlessPlayback(ref); @@ -172,21 +179,35 @@ class RootApp extends HookConsumerWidget { return null; }, [backgroundColor]); + void onSelectIndexChanged(int d) { + final invertedRouteMap = + rootPaths.map((key, value) => MapEntry(value, key)); + + if (context.mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + GoRouter.of(context).go(invertedRouteMap[d]!); + }); + } + } + // ignore: deprecated_member_use return WillPopScope( onWillPop: () async { - final routerState = GoRouterState.of(context); - if (routerState.matchedLocation != "/") { - context.goNamed(HomePage.name); + if (rootPaths[location] != 0) { + onSelectIndexChanged(0); return false; } return true; }, child: Scaffold( - body: Sidebar(child: child), + body: Sidebar( + selectedIndex: rootPaths[location], + onSelectedIndexChanged: onSelectIndexChanged, + child: child, + ), extendBody: true, drawerScrimColor: Colors.transparent, - endDrawer: kIsDesktop + endDrawer: DesktopTools.platform.isDesktop ? Container( constraints: const BoxConstraints(maxWidth: 800), decoration: BoxDecoration( @@ -217,7 +238,10 @@ class RootApp extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ BottomPlayer(), - const SpotubeNavigationBar(), + SpotubeNavigationBar( + selectedIndex: rootPaths[location], + onSelectedIndexChanged: onSelectIndexChanged, + ), ], ), ), diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 50ef152b..e9ada236 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart' hide Page; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:gap/gap.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -27,8 +26,6 @@ import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; class SearchPage extends HookConsumerWidget { - static const name = "search"; - const SearchPage({super.key}); @override @@ -88,117 +85,99 @@ class SearchPage extends HookConsumerWidget { return SafeArea( bottom: false, child: Scaffold( - appBar: kIsDesktop && !kIsMacOS - ? const PageWindowTitleBar(automaticallyImplyLeading: true) - : null, + appBar: kIsDesktop && !kIsMacOS ? const PageWindowTitleBar() : null, body: !authenticationNotifier.isLoggedIn ? const AnonymousFallback() : Column( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - if ((kIsMobile || kIsMacOS) && context.canPop()) - const BackButton() - else - const Gap(20), - Expanded( - child: Padding( - padding: const EdgeInsets.only( - right: 20, - top: 20, - bottom: 20, - ), - child: SearchAnchor( - searchController: controller, - viewBuilder: (_) => HookBuilder(builder: (context) { - final searchController = - useListenable(controller); - final update = useForceUpdate(); - final suggestions = searchController.text.isEmpty - ? KVStoreService.recentSearches - : KVStoreService.recentSearches - .where( - (s) => - weightedRatio( - s.toLowerCase(), - searchController.text - .toLowerCase(), - ) > - 50, - ) - .toList(); + Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + color: theme.scaffoldBackgroundColor, + child: SearchAnchor( + searchController: controller, + viewBuilder: (_) => HookBuilder(builder: (context) { + final searchController = useListenable(controller); + final update = useForceUpdate(); + final suggestions = searchController.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + searchController.text.toLowerCase(), + ) > + 50, + ) + .toList(); - return ListView.builder( - itemCount: suggestions.length, - itemBuilder: (context, index) { - final suggestion = suggestions[index]; + return ListView.builder( + itemCount: suggestions.length, + itemBuilder: (context, index) { + final suggestion = suggestions[index]; - return ListTile( - leading: const Icon(SpotubeIcons.history), - title: Text(suggestion), - trailing: IconButton( - icon: const Icon(SpotubeIcons.trash), - onPressed: () { - KVStoreService.setRecentSearches( - KVStoreService.recentSearches - .where((s) => s != suggestion) - .toList(), - ); - update(); - }, - ), - onTap: () { - controller.closeView(suggestion); - ref - .read( - searchTermStateProvider.notifier) - .state = suggestion; - }, - ); - }, - ); - }), - suggestionsBuilder: (context, controller) { - return []; - }, - viewOnSubmitted: (value) async { - controller.closeView(value); - Timer( - const Duration(milliseconds: 50), - () { - ref - .read(searchTermStateProvider.notifier) - .state = value; - if (value.trim().isEmpty) { - return; - } + return ListTile( + leading: const Icon(SpotubeIcons.history), + title: Text(suggestion), + trailing: IconButton( + icon: const Icon(SpotubeIcons.trash), + onPressed: () { KVStoreService.setRecentSearches( - { - value, - ...KVStoreService.recentSearches, - }.toList(), + KVStoreService.recentSearches + .where((s) => s != suggestion) + .toList(), ); + update(); }, - ); - }, - builder: (context, controller) { - return SearchBar( - autoFocus: queries.none((s) => - s.asData?.value != null && - !s.hasError) && - !kIsMobile, - controller: controller, - leading: const Icon(SpotubeIcons.search), - hintText: "${context.l10n.search}...", - onTap: controller.openView, - onChanged: (_) => controller.openView(), - ); - }, - ), - ), - ), - ], + ), + onTap: () { + controller.closeView(suggestion); + ref + .read(searchTermStateProvider.notifier) + .state = suggestion; + }, + ); + }, + ); + }), + suggestionsBuilder: (context, controller) { + return []; + }, + viewOnSubmitted: (value) async { + controller.closeView(value); + Timer( + const Duration(milliseconds: 50), + () { + ref.read(searchTermStateProvider.notifier).state = + value; + if (value.trim().isEmpty) { + return; + } + KVStoreService.setRecentSearches( + { + value, + ...KVStoreService.recentSearches, + }.toList(), + ); + }, + ); + }, + builder: (context, controller) { + return SearchBar( + autoFocus: queries.none((s) => + s.asData?.value != null && !s.hasError) && + !kIsMobile, + controller: controller, + leading: const Icon(SpotubeIcons.search), + hintText: "${context.l10n.search}...", + onTap: controller.openView, + onChanged: (_) => controller.openView(), + ); + }, + ), ), Expanded( child: AnimatedSwitcher( @@ -212,7 +191,7 @@ class SearchPage extends HookConsumerWidget { Icon( SpotubeIcons.web, size: 120, - color: theme.colorScheme.onSurface + color: theme.colorScheme.onBackground .withOpacity(0.7), ), const SizedBox(height: 20), @@ -220,7 +199,7 @@ class SearchPage extends HookConsumerWidget { context.l10n.search_to_get_results, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w900, - color: theme.colorScheme.onSurface + color: theme.colorScheme.onBackground .withOpacity(0.5), ), ), @@ -246,7 +225,7 @@ class SearchPage extends HookConsumerWidget { style: TextStyle( fontSize: 20, fontWeight: FontWeight.w900, - color: theme.colorScheme.onSurface + color: theme.colorScheme.onBackground .withOpacity(0.7), ), ), diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index bd7f3c88..48dabc13 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -76,7 +76,7 @@ class SearchTracksSection extends HookConsumerWidget { if (shouldPlay) { await remotePlayback.load( - WebSocketLoadEventData.playlist( + WebSocketLoadEventData( tracks: [track], ), ); @@ -113,7 +113,7 @@ class SearchTracksSection extends HookConsumerWidget { child: TextButton( onPressed: searchTrack.isLoadingNextPage ? null - : searchTrackNotifier.fetchMore, + : () => searchTrackNotifier.fetchMore, child: searchTrack.isLoadingNextPage ? const CircularProgressIndicator() : Text(context.l10n.load_more), diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index e7d95759..21b8117b 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/collections/env.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; @@ -17,8 +16,6 @@ final _licenseProvider = FutureProvider((ref) async { }); class AboutSpotube extends HookConsumerWidget { - static const name = "about"; - const AboutSpotube({super.key}); @override @@ -75,13 +72,6 @@ class AboutSpotube extends HookConsumerWidget { Text("v${packageInfo.version}") ], ), - TableRow( - children: [ - Text(context.l10n.channel), - colon, - Text(Env.releaseChannel.name) - ], - ), TableRow( children: [ Text(context.l10n.build_number), diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 4e937922..9dd85c50 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -11,8 +11,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; class BlackListPage extends HookConsumerWidget { - static const name = "blacklist"; - const BlackListPage({super.key}); @override @@ -20,6 +18,7 @@ class BlackListPage extends HookConsumerWidget { final controller = useScrollController(); final blacklist = ref.watch(blacklistProvider); final searchText = useState(""); + final filteredBlacklist = useMemoized( () { if (searchText.value.isEmpty) { diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index 8b6f7312..b07ebbb1 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -11,8 +11,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; class LogsPage extends HookWidget { - static const name = "logs"; - const LogsPage({super.key}); List<({DateTime? date, String body})> parseLogs(String raw) { diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index 5e5d2377..a8d72cc0 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -43,9 +43,10 @@ class SettingsAboutSection extends HookConsumerWidget { ), trailing: (context, update) => FilledButton( style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.red[100]), - foregroundColor: const WidgetStatePropertyAll(Colors.pinkAccent), - padding: const WidgetStatePropertyAll(EdgeInsets.all(15)), + backgroundColor: MaterialStatePropertyAll(Colors.red[100]), + foregroundColor: + const MaterialStatePropertyAll(Colors.pinkAccent), + padding: const MaterialStatePropertyAll(EdgeInsets.all(15)), ), onPressed: () { launchUrlString( diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 5acab480..ab3a7c92 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -4,15 +4,10 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; -import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/service_utils.dart'; class SettingsAccountSection extends HookConsumerWidget { const SettingsAccountSection({super.key}); @@ -20,12 +15,9 @@ class SettingsAccountSection extends HookConsumerWidget { @override Widget build(context, ref) { final theme = Theme.of(context); - final router = GoRouter.of(context); - final auth = ref.watch(authenticationProvider); final scrobbler = ref.watch(scrobblerProvider); - final me = ref.watch(meProvider); - final meData = me.asData?.value; + final router = GoRouter.of(context); final logoutBtnStyle = FilledButton.styleFrom( backgroundColor: Colors.red, @@ -35,24 +27,6 @@ class SettingsAccountSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.account, children: [ - if (auth != null) - ListTile( - leading: const Icon(SpotubeIcons.user), - title: const Text("User Profile"), - trailing: Padding( - padding: const EdgeInsets.all(8.0), - child: CircleAvatar( - backgroundImage: UniversalImage.imageProvider( - (meData?.images).asUrlString( - placeholder: ImagePlaceholder.artist, - ), - ), - ), - ), - onTap: () { - ServiceUtils.pushNamed(context, ProfilePage.name); - }, - ), if (auth == null) LayoutBuilder(builder: (context, constrains) { return ListTile( @@ -82,7 +56,7 @@ class SettingsAccountSection extends HookConsumerWidget { router.push("/login"); }, style: ButtonStyle( - shape: WidgetStateProperty.all( + shape: MaterialStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(25.0), ), diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index 56306868..4e4408d9 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -7,7 +8,6 @@ import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/utils/platform.dart'; class SettingsDesktopSection extends HookConsumerWidget { const SettingsDesktopSection({super.key}); @@ -53,7 +53,7 @@ class SettingsDesktopSection extends HookConsumerWidget { value: preferences.systemTitleBar, onChanged: preferencesNotifier.setSystemTitleBar, ), - if (!kIsMacOS) + if (!DesktopTools.platform.isMacOS) SwitchListTile( secondary: const Icon(SpotubeIcons.discord), title: Text(context.l10n.discord_rich_presence), diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index 76ef8e3e..1f25028e 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -1,13 +1,13 @@ import 'package:file_picker/file_picker.dart'; import 'package:file_selector/file_selector.dart'; 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/collections/spotube_icons.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; class SettingsDownloadsSection extends HookConsumerWidget { const SettingsDownloadsSection({super.key}); @@ -18,7 +18,7 @@ class SettingsDownloadsSection extends HookConsumerWidget { final preferences = ref.watch(userPreferencesProvider); final pickDownloadLocation = useCallback(() async { - if (kIsMobile || kIsMacOS) { + if (DesktopTools.platform.isMobile || DesktopTools.platform.isMacOS) { final dirStr = await FilePicker.platform.getDirectoryPath( initialDirectory: preferences.downloadLocation, ); diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index af0fc095..d2a75057 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; 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/shared/page_window_title_bar.dart'; @@ -13,11 +14,8 @@ import 'package:spotube/pages/settings/sections/downloads.dart'; import 'package:spotube/pages/settings/sections/language_region.dart'; import 'package:spotube/pages/settings/sections/playback.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; class SettingsPage extends HookConsumerWidget { - static const name = "settings"; - const SettingsPage({super.key}); @override @@ -31,7 +29,6 @@ class SettingsPage extends HookConsumerWidget { appBar: PageWindowTitleBar( title: Text(context.l10n.settings), centerTitle: true, - automaticallyImplyLeading: true, ), body: Scrollbar( controller: controller, @@ -48,7 +45,8 @@ class SettingsPage extends HookConsumerWidget { const SettingsAppearanceSection(), const SettingsPlaybackSection(), const SettingsDownloadsSection(), - if (kIsDesktop) const SettingsDesktopSection(), + if (DesktopTools.platform.isDesktop) + const SettingsDesktopSection(), if (!kIsWeb) const SettingsDevelopersSection(), const SettingsAboutSection(), Center( diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart deleted file mode 100644 index 83867f93..00000000 --- a/lib/pages/stats/albums/albums.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/album_item.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/history/top.dart'; - -class StatsAlbumsPage extends HookConsumerWidget { - static const name = "stats_albums"; - const StatsAlbumsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final albums = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.albums), - ); - - return Scaffold( - appBar: const PageWindowTitleBar( - automaticallyImplyLeading: true, - centerTitle: false, - title: Text("Albums"), - ), - body: ListView.builder( - itemCount: albums.length, - itemBuilder: (context, index) { - final album = albums[index]; - return StatsAlbumItem( - album: album.album, - info: Text("${compactNumberFormatter.format(album.count)} plays"), - ); - }, - ), - ); - } -} diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart deleted file mode 100644 index 755475ae..00000000 --- a/lib/pages/stats/artists/artists.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/artist_item.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/history/top.dart'; - -class StatsArtistsPage extends HookConsumerWidget { - static const name = "stats_artists"; - const StatsArtistsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final artists = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.artists), - ); - - return Scaffold( - appBar: const PageWindowTitleBar( - automaticallyImplyLeading: true, - centerTitle: false, - title: Text("Artists"), - ), - body: ListView.builder( - itemCount: artists.length, - itemBuilder: (context, index) { - final artist = artists[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text("${compactNumberFormatter.format(artist.count)} plays"), - ); - }, - ), - ); - } -} diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart deleted file mode 100644 index 228d3243..00000000 --- a/lib/pages/stats/fees/fees.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:sliver_tools/sliver_tools.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/artist_item.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/history/top.dart'; - -class StatsStreamFeesPage extends HookConsumerWidget { - static const name = "stats_stream_fees"; - - const StatsStreamFeesPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :hintColor) = Theme.of(context); - - final artists = ref.watch( - playbackHistoryTopProvider(HistoryDuration.days30) - .select((value) => value.artists), - ); - - return Scaffold( - appBar: const PageWindowTitleBar( - automaticallyImplyLeading: true, - centerTitle: false, - title: Text("Streaming fees (hypothetical)"), - ), - body: CustomScrollView( - slivers: [ - SliverCrossAxisConstrained( - maxCrossAxisExtent: 600, - alignment: -1, - child: SliverPadding( - padding: const EdgeInsets.all(16.0), - sliver: SliverToBoxAdapter( - child: Text( - "*This is calculated based on Spotify's per stream " - "payout of \$0.003 to \$0.005. This is a hypothetical " - "calculation to give user insight about how much they " - "would have paid to the artists if they were to listen " - "their song in Spotify.", - style: textTheme.bodySmall?.copyWith( - color: hintColor, - ), - ), - ), - ), - ), - SliverList.builder( - itemCount: artists.length, - itemBuilder: (context, index) { - final artist = artists[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text(usdFormatter.format(artist.count * 0.005)), - ); - }, - ), - ], - ), - ); - } -} diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart deleted file mode 100644 index b22f9a4f..00000000 --- a/lib/pages/stats/minutes/minutes.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/track_item.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/history/top.dart'; - -class StatsMinutesPage extends HookConsumerWidget { - static const name = "stats_minutes"; - - const StatsMinutesPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final topTracks = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.tracks), - ); - - return Scaffold( - appBar: const PageWindowTitleBar( - title: Text("Minutes listened"), - centerTitle: false, - automaticallyImplyLeading: true, - ), - body: ListView.separated( - separatorBuilder: (context, index) => const Gap(8), - itemCount: topTracks.length, - itemBuilder: (context, index) { - final (:track, :count) = topTracks[index]; - - return StatsTrackItem( - track: track, - info: Text( - "${compactNumberFormatter.format(count * track.duration!.inMinutes)} mins", - ), - ); - }, - ), - ); - } -} diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart deleted file mode 100644 index cca7febb..00000000 --- a/lib/pages/stats/playlists/playlists.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/playlist_item.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/history/top.dart'; - -class StatsPlaylistsPage extends HookConsumerWidget { - static const name = "stats_playlists"; - const StatsPlaylistsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final playlists = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.playlists), - ); - - return Scaffold( - appBar: const PageWindowTitleBar( - automaticallyImplyLeading: true, - centerTitle: false, - title: Text("Playlists"), - ), - body: ListView.builder( - itemCount: playlists.length, - itemBuilder: (context, index) { - final playlist = playlists[index]; - return StatsPlaylistItem( - playlist: playlist.playlist.playlist, - info: - Text("${compactNumberFormatter.format(playlist.count)} plays"), - ); - }, - ), - ); - } -} diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart deleted file mode 100644 index 95493591..00000000 --- a/lib/pages/stats/stats.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/summary/summary.dart'; -import 'package:spotube/components/stats/top/top.dart'; -import 'package:spotube/utils/platform.dart'; - -class StatsPage extends HookConsumerWidget { - static const name = "stats"; - - const StatsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - return SafeArea( - bottom: false, - child: Scaffold( - appBar: kIsMacOS || kIsMobile ? null : const PageWindowTitleBar(), - body: CustomScrollView( - slivers: [ - if (kIsMacOS) const SliverGap(20), - const StatsPageSummarySection(), - const StatsPageTopSection(), - const SliverToBoxAdapter( - child: SafeArea( - child: SizedBox(), - ), - ) - ], - ), - ), - ); - } -} diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart deleted file mode 100644 index 33480709..00000000 --- a/lib/pages/stats/streams/streams.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:gap/gap.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/track_item.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/history/top.dart'; - -class StatsStreamsPage extends HookConsumerWidget { - static const name = "stats_streams"; - - const StatsStreamsPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final topTracks = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.tracks), - ); - - return Scaffold( - appBar: const PageWindowTitleBar( - title: Text("Streamed songs"), - centerTitle: false, - automaticallyImplyLeading: true, - ), - body: ListView.separated( - separatorBuilder: (context, index) => const Gap(8), - itemCount: topTracks.length, - itemBuilder: (context, index) { - final (:track, :count) = topTracks[index]; - - return StatsTrackItem( - track: track, - info: Text( - "${compactNumberFormatter.format(count)} streams", - ), - ); - }, - ), - ); - } -} diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index 2109fe6e..fc90d19a 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -21,8 +21,6 @@ import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/extensions/constrains.dart'; class TrackPage extends HookConsumerWidget { - static const name = "track"; - final String trackId; const TrackPage({ super.key, diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index be61cb4f..a82f82c0 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -1,12 +1,10 @@ import 'dart:async'; -import 'dart:io'; +import 'dart:convert'; import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart' - hide X509Certificate; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/extensions/context.dart'; @@ -20,18 +18,6 @@ class AuthenticationCredentials { bool get isExpired => DateTime.now().isAfter(expiration); - static final Dio dio = () { - final dio = Dio(); - - (dio.httpClientAdapter as IOHttpClientAdapter) - .createHttpClient = () => HttpClient() - ..badCertificateCallback = (X509Certificate cert, String host, int port) { - return host.endsWith("spotify.com") && port == 443; - }; - - return dio; - }(); - AuthenticationCredentials({ required this.cookie, required this.accessToken, @@ -44,29 +30,26 @@ class AuthenticationCredentials { .split("; ") .firstWhereOrNull((c) => c.trim().startsWith("sp_dc=")) ?.trim(); - final res = await dio.getUri( + final res = await get( Uri.parse( "https://open.spotify.com/get_access_token?reason=transport&productType=web_player", ), - options: Options( - headers: { - "Cookie": spDc ?? "", - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" - }, - validateStatus: (status) => true, - ), + headers: { + "Cookie": spDc ?? "", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36" + }, ); - final body = res.data; + final body = jsonDecode(res.body); - if ((res.statusCode ?? 500) >= 400) { + if (res.statusCode >= 400) { throw Exception( - "Failed to get access token: ${body['error'] ?? res.statusMessage}", + "Failed to get access token: ${body['error'] ?? res.reasonPhrase}", ); } return AuthenticationCredentials( - cookie: "${res.headers["set-cookie"]?.join(";")}; $spDc", + cookie: "${res.headers["set-cookie"]}; $spDc", accessToken: body['accessToken'], expiration: DateTime.fromMillisecondsSinceEpoch( body['accessTokenExpirationTimestampMs'], diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart index 9c4e6466..ebf53e43 100644 --- a/lib/provider/connect/server.dart +++ b/lib/provider/connect/server.dart @@ -9,11 +9,9 @@ import 'package:shelf/shelf_io.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -34,7 +32,6 @@ final connectServerProvider = FutureProvider((ref) async { final resolvedService = await ref .watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); final playbackNotifier = ref.read(proxyPlaylistProvider.notifier); - final historyNotifier = ref.read(playbackHistoryProvider.notifier); if (!enabled || resolvedService != null) { return null; @@ -82,7 +79,7 @@ final connectServerProvider = FutureProvider((ref) async { .toJson(), ); channel.sink.add( - WebSocketShuffleEvent(audioPlayer.isShuffled).toJson(), + WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(), ); channel.sink.add( WebSocketLoopEvent(audioPlayer.loopMode).toJson(), @@ -149,14 +146,8 @@ final connectServerProvider = FutureProvider((ref) async { initialIndex: event.data.initialIndex ?? 0, ); - if (event.data.collectionId == null) return; - playbackNotifier.addCollection(event.data.collectionId!); - if (event.data.collection is AlbumSimple) { - historyNotifier - .addAlbums([event.data.collection as AlbumSimple]); - } else { - historyNotifier.addPlaylists( - [event.data.collection as PlaylistSimple]); + if (event.data.collectionId != null) { + playbackNotifier.addCollection(event.data.collectionId!); } }); diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index f90db54a..ca8eecfa 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -1,19 +1,21 @@ 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/extensions/artist_simple.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/platform.dart'; class Discord extends ChangeNotifier { final DiscordRPC? discordRPC; final bool isEnabled; Discord(this.isEnabled) - : discordRPC = (kIsWindows || kIsLinux) && isEnabled + : discordRPC = (DesktopTools.platform.isWindows || + DesktopTools.platform.isLinux) && + isEnabled ? DiscordRPC(applicationId: Env.discordAppId) : null { discordRPC?.start(autoRegister: true); diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart deleted file mode 100644 index 4436626d..00000000 --- a/lib/provider/history/history.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; - -class PlaybackHistoryState { - final List items; - const PlaybackHistoryState({this.items = const []}); - - factory PlaybackHistoryState.fromJson(Map json) { - return PlaybackHistoryState( - items: json["items"] - ?.map( - (json) => PlaybackHistoryItem.fromJson(json), - ) - .toList() - .cast() ?? - [], - ); - } - - Map toJson() { - return { - "items": items.map((s) => s.toJson()).toList(), - }; - } - - PlaybackHistoryState copyWith({ - List? items, - }) { - return PlaybackHistoryState(items: items ?? this.items); - } -} - -class PlaybackHistoryNotifier - extends PersistedStateNotifier { - final Ref ref; - PlaybackHistoryNotifier(this.ref) - : super(const PlaybackHistoryState(), "playback_history"); - - SpotifyApi get spotify => ref.read(spotifyProvider); - - @override - FutureOr fromJson(Map json) => - PlaybackHistoryState.fromJson(json); - - @override - Map toJson() { - return state.toJson(); - } - - void addPlaylists(List playlists) { - state = state.copyWith( - items: [ - ...state.items, - for (final playlist in playlists) - PlaybackHistoryItem.playlist( - date: DateTime.now(), playlist: playlist), - ], - ); - } - - void addAlbums(List albums) { - state = state.copyWith( - items: [ - ...state.items, - for (final album in albums) - PlaybackHistoryItem.album(date: DateTime.now(), album: album), - ], - ); - } - - void addTrack(Track track) async { - // For some reason Track's artists images are `null` - // so we need to fetch them from the API - final artists = - await spotify.artists.list(track.artists!.map((e) => e.id!).toList()); - - track.artists = artists.toList(); - - state = state.copyWith( - items: [ - ...state.items, - PlaybackHistoryItem.track(date: DateTime.now(), track: track), - ], - ); - } - - void clear() { - state = state.copyWith(items: []); - } -} - -final playbackHistoryProvider = - StateNotifierProvider( - (ref) => PlaybackHistoryNotifier(ref), -); - -typedef PlaybackHistoryGrouped = ({ - List tracks, - List albums, - List playlists, -}); - -final playbackHistoryGroupedProvider = Provider((ref) { - final history = ref.watch(playbackHistoryProvider); - final tracks = history.items - .whereType() - .sorted((a, b) => b.date.compareTo(a.date)) - .toList(); - final albums = history.items - .whereType() - .sorted((a, b) => b.date.compareTo(a.date)) - .toList(); - final playlists = history.items - .whereType() - .sorted((a, b) => b.date.compareTo(a.date)) - .toList(); - - return ( - tracks: tracks, - albums: albums, - playlists: playlists, - ); -}); diff --git a/lib/provider/history/recent.dart b/lib/provider/history/recent.dart deleted file mode 100644 index 9953858d..00000000 --- a/lib/provider/history/recent.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/history/state.dart'; - -final recentlyPlayedItems = Provider((ref) { - return ref.watch( - playbackHistoryProvider.select( - (s) => s.items - .toSet() - // unique items - .whereIndexed( - (index, item) => - index == - s.items.lastIndexWhere( - (e) => switch ((e, item)) { - ( - PlaybackHistoryPlaylist(:final playlist), - PlaybackHistoryPlaylist(playlist: final playlist2) - ) => - playlist.id == playlist2.id, - ( - PlaybackHistoryAlbum(:final album), - PlaybackHistoryAlbum(album: final album2) - ) => - album.id == album2.id, - _ => false, - }, - ), - ) - .where( - (s) => s is PlaybackHistoryPlaylist || s is PlaybackHistoryAlbum, - ) - .take(10) - .sortedBy((s) => s.date) - .reversed - .toList(), - ), - ); -}); diff --git a/lib/provider/history/state.dart b/lib/provider/history/state.dart deleted file mode 100644 index 67658502..00000000 --- a/lib/provider/history/state.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:spotify/spotify.dart'; - -part 'state.freezed.dart'; -part 'state.g.dart'; - -enum HistoryDuration { - allTime, - days7, - days30, - months6, - year, - years2, -} - -@freezed -class PlaybackHistoryItem with _$PlaybackHistoryItem { - factory PlaybackHistoryItem.playlist({ - required DateTime date, - required PlaylistSimple playlist, - }) = PlaybackHistoryPlaylist; - - factory PlaybackHistoryItem.album({ - required DateTime date, - required AlbumSimple album, - }) = PlaybackHistoryAlbum; - - factory PlaybackHistoryItem.track({ - required DateTime date, - required Track track, - }) = PlaybackHistoryTrack; - - factory PlaybackHistoryItem.fromJson(Map json) => - _$PlaybackHistoryItemFromJson(json); -} diff --git a/lib/provider/history/state.freezed.dart b/lib/provider/history/state.freezed.dart deleted file mode 100644 index e2ee9421..00000000 --- a/lib/provider/history/state.freezed.dart +++ /dev/null @@ -1,644 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -PlaybackHistoryItem _$PlaybackHistoryItemFromJson(Map json) { - switch (json['runtimeType']) { - case 'playlist': - return PlaybackHistoryPlaylist.fromJson(json); - case 'album': - return PlaybackHistoryAlbum.fromJson(json); - case 'track': - return PlaybackHistoryTrack.fromJson(json); - - default: - throw CheckedFromJsonException(json, 'runtimeType', 'PlaybackHistoryItem', - 'Invalid union type "${json['runtimeType']}"!'); - } -} - -/// @nodoc -mixin _$PlaybackHistoryItem { - DateTime get date => throw _privateConstructorUsedError; - @optionalTypeArgs - TResult when({ - required TResult Function(DateTime date, PlaylistSimple playlist) playlist, - required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, Track track) track, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, Track track)? track, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, Track track)? track, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult map({ - required TResult Function(PlaybackHistoryPlaylist value) playlist, - required TResult Function(PlaybackHistoryAlbum value) album, - required TResult Function(PlaybackHistoryTrack value) track, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(PlaybackHistoryPlaylist value)? playlist, - TResult? Function(PlaybackHistoryAlbum value)? album, - TResult? Function(PlaybackHistoryTrack value)? track, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeMap({ - TResult Function(PlaybackHistoryPlaylist value)? playlist, - TResult Function(PlaybackHistoryAlbum value)? album, - TResult Function(PlaybackHistoryTrack value)? track, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $PlaybackHistoryItemCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $PlaybackHistoryItemCopyWith<$Res> { - factory $PlaybackHistoryItemCopyWith( - PlaybackHistoryItem value, $Res Function(PlaybackHistoryItem) then) = - _$PlaybackHistoryItemCopyWithImpl<$Res, PlaybackHistoryItem>; - @useResult - $Res call({DateTime date}); -} - -/// @nodoc -class _$PlaybackHistoryItemCopyWithImpl<$Res, $Val extends PlaybackHistoryItem> - implements $PlaybackHistoryItemCopyWith<$Res> { - _$PlaybackHistoryItemCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? date = null, - }) { - return _then(_value.copyWith( - date: null == date - ? _value.date - : date // ignore: cast_nullable_to_non_nullable - as DateTime, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$PlaybackHistoryPlaylistImplCopyWith<$Res> - implements $PlaybackHistoryItemCopyWith<$Res> { - factory _$$PlaybackHistoryPlaylistImplCopyWith( - _$PlaybackHistoryPlaylistImpl value, - $Res Function(_$PlaybackHistoryPlaylistImpl) then) = - __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({DateTime date, PlaylistSimple playlist}); -} - -/// @nodoc -class __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res> - extends _$PlaybackHistoryItemCopyWithImpl<$Res, - _$PlaybackHistoryPlaylistImpl> - implements _$$PlaybackHistoryPlaylistImplCopyWith<$Res> { - __$$PlaybackHistoryPlaylistImplCopyWithImpl( - _$PlaybackHistoryPlaylistImpl _value, - $Res Function(_$PlaybackHistoryPlaylistImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? date = null, - Object? playlist = null, - }) { - return _then(_$PlaybackHistoryPlaylistImpl( - date: null == date - ? _value.date - : date // ignore: cast_nullable_to_non_nullable - as DateTime, - playlist: null == playlist - ? _value.playlist - : playlist // ignore: cast_nullable_to_non_nullable - as PlaylistSimple, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist { - _$PlaybackHistoryPlaylistImpl( - {required this.date, required this.playlist, final String? $type}) - : $type = $type ?? 'playlist'; - - factory _$PlaybackHistoryPlaylistImpl.fromJson(Map json) => - _$$PlaybackHistoryPlaylistImplFromJson(json); - - @override - final DateTime date; - @override - final PlaylistSimple playlist; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'PlaybackHistoryItem.playlist(date: $date, playlist: $playlist)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$PlaybackHistoryPlaylistImpl && - (identical(other.date, date) || other.date == date) && - (identical(other.playlist, playlist) || - other.playlist == playlist)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, date, playlist); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> - get copyWith => __$$PlaybackHistoryPlaylistImplCopyWithImpl< - _$PlaybackHistoryPlaylistImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(DateTime date, PlaylistSimple playlist) playlist, - required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, Track track) track, - }) { - return playlist(date, this.playlist); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, Track track)? track, - }) { - return playlist?.call(date, this.playlist); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, Track track)? track, - required TResult orElse(), - }) { - if (playlist != null) { - return playlist(date, this.playlist); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(PlaybackHistoryPlaylist value) playlist, - required TResult Function(PlaybackHistoryAlbum value) album, - required TResult Function(PlaybackHistoryTrack value) track, - }) { - return playlist(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(PlaybackHistoryPlaylist value)? playlist, - TResult? Function(PlaybackHistoryAlbum value)? album, - TResult? Function(PlaybackHistoryTrack value)? track, - }) { - return playlist?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(PlaybackHistoryPlaylist value)? playlist, - TResult Function(PlaybackHistoryAlbum value)? album, - TResult Function(PlaybackHistoryTrack value)? track, - required TResult orElse(), - }) { - if (playlist != null) { - return playlist(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$PlaybackHistoryPlaylistImplToJson( - this, - ); - } -} - -abstract class PlaybackHistoryPlaylist implements PlaybackHistoryItem { - factory PlaybackHistoryPlaylist( - {required final DateTime date, - required final PlaylistSimple playlist}) = _$PlaybackHistoryPlaylistImpl; - - factory PlaybackHistoryPlaylist.fromJson(Map json) = - _$PlaybackHistoryPlaylistImpl.fromJson; - - @override - DateTime get date; - PlaylistSimple get playlist; - @override - @JsonKey(ignore: true) - _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class _$$PlaybackHistoryAlbumImplCopyWith<$Res> - implements $PlaybackHistoryItemCopyWith<$Res> { - factory _$$PlaybackHistoryAlbumImplCopyWith(_$PlaybackHistoryAlbumImpl value, - $Res Function(_$PlaybackHistoryAlbumImpl) then) = - __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({DateTime date, AlbumSimple album}); -} - -/// @nodoc -class __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res> - extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryAlbumImpl> - implements _$$PlaybackHistoryAlbumImplCopyWith<$Res> { - __$$PlaybackHistoryAlbumImplCopyWithImpl(_$PlaybackHistoryAlbumImpl _value, - $Res Function(_$PlaybackHistoryAlbumImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? date = null, - Object? album = null, - }) { - return _then(_$PlaybackHistoryAlbumImpl( - date: null == date - ? _value.date - : date // ignore: cast_nullable_to_non_nullable - as DateTime, - album: null == album - ? _value.album - : album // ignore: cast_nullable_to_non_nullable - as AlbumSimple, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum { - _$PlaybackHistoryAlbumImpl( - {required this.date, required this.album, final String? $type}) - : $type = $type ?? 'album'; - - factory _$PlaybackHistoryAlbumImpl.fromJson(Map json) => - _$$PlaybackHistoryAlbumImplFromJson(json); - - @override - final DateTime date; - @override - final AlbumSimple album; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'PlaybackHistoryItem.album(date: $date, album: $album)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$PlaybackHistoryAlbumImpl && - (identical(other.date, date) || other.date == date) && - (identical(other.album, album) || other.album == album)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, date, album); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> - get copyWith => - __$$PlaybackHistoryAlbumImplCopyWithImpl<_$PlaybackHistoryAlbumImpl>( - this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(DateTime date, PlaylistSimple playlist) playlist, - required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, Track track) track, - }) { - return album(date, this.album); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, Track track)? track, - }) { - return album?.call(date, this.album); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, Track track)? track, - required TResult orElse(), - }) { - if (album != null) { - return album(date, this.album); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(PlaybackHistoryPlaylist value) playlist, - required TResult Function(PlaybackHistoryAlbum value) album, - required TResult Function(PlaybackHistoryTrack value) track, - }) { - return album(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(PlaybackHistoryPlaylist value)? playlist, - TResult? Function(PlaybackHistoryAlbum value)? album, - TResult? Function(PlaybackHistoryTrack value)? track, - }) { - return album?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(PlaybackHistoryPlaylist value)? playlist, - TResult Function(PlaybackHistoryAlbum value)? album, - TResult Function(PlaybackHistoryTrack value)? track, - required TResult orElse(), - }) { - if (album != null) { - return album(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$PlaybackHistoryAlbumImplToJson( - this, - ); - } -} - -abstract class PlaybackHistoryAlbum implements PlaybackHistoryItem { - factory PlaybackHistoryAlbum( - {required final DateTime date, - required final AlbumSimple album}) = _$PlaybackHistoryAlbumImpl; - - factory PlaybackHistoryAlbum.fromJson(Map json) = - _$PlaybackHistoryAlbumImpl.fromJson; - - @override - DateTime get date; - AlbumSimple get album; - @override - @JsonKey(ignore: true) - _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class _$$PlaybackHistoryTrackImplCopyWith<$Res> - implements $PlaybackHistoryItemCopyWith<$Res> { - factory _$$PlaybackHistoryTrackImplCopyWith(_$PlaybackHistoryTrackImpl value, - $Res Function(_$PlaybackHistoryTrackImpl) then) = - __$$PlaybackHistoryTrackImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({DateTime date, Track track}); -} - -/// @nodoc -class __$$PlaybackHistoryTrackImplCopyWithImpl<$Res> - extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryTrackImpl> - implements _$$PlaybackHistoryTrackImplCopyWith<$Res> { - __$$PlaybackHistoryTrackImplCopyWithImpl(_$PlaybackHistoryTrackImpl _value, - $Res Function(_$PlaybackHistoryTrackImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? date = null, - Object? track = null, - }) { - return _then(_$PlaybackHistoryTrackImpl( - date: null == date - ? _value.date - : date // ignore: cast_nullable_to_non_nullable - as DateTime, - track: null == track - ? _value.track - : track // ignore: cast_nullable_to_non_nullable - as Track, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { - _$PlaybackHistoryTrackImpl( - {required this.date, required this.track, final String? $type}) - : $type = $type ?? 'track'; - - factory _$PlaybackHistoryTrackImpl.fromJson(Map json) => - _$$PlaybackHistoryTrackImplFromJson(json); - - @override - final DateTime date; - @override - final Track track; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'PlaybackHistoryItem.track(date: $date, track: $track)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$PlaybackHistoryTrackImpl && - (identical(other.date, date) || other.date == date) && - (identical(other.track, track) || other.track == track)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, date, track); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> - get copyWith => - __$$PlaybackHistoryTrackImplCopyWithImpl<_$PlaybackHistoryTrackImpl>( - this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(DateTime date, PlaylistSimple playlist) playlist, - required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, Track track) track, - }) { - return track(date, this.track); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, Track track)? track, - }) { - return track?.call(date, this.track); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, Track track)? track, - required TResult orElse(), - }) { - if (track != null) { - return track(date, this.track); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(PlaybackHistoryPlaylist value) playlist, - required TResult Function(PlaybackHistoryAlbum value) album, - required TResult Function(PlaybackHistoryTrack value) track, - }) { - return track(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(PlaybackHistoryPlaylist value)? playlist, - TResult? Function(PlaybackHistoryAlbum value)? album, - TResult? Function(PlaybackHistoryTrack value)? track, - }) { - return track?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(PlaybackHistoryPlaylist value)? playlist, - TResult Function(PlaybackHistoryAlbum value)? album, - TResult Function(PlaybackHistoryTrack value)? track, - required TResult orElse(), - }) { - if (track != null) { - return track(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$PlaybackHistoryTrackImplToJson( - this, - ); - } -} - -abstract class PlaybackHistoryTrack implements PlaybackHistoryItem { - factory PlaybackHistoryTrack( - {required final DateTime date, - required final Track track}) = _$PlaybackHistoryTrackImpl; - - factory PlaybackHistoryTrack.fromJson(Map json) = - _$PlaybackHistoryTrackImpl.fromJson; - - @override - DateTime get date; - Track get track; - @override - @JsonKey(ignore: true) - _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> - get copyWith => throw _privateConstructorUsedError; -} diff --git a/lib/provider/history/state.g.dart b/lib/provider/history/state.g.dart deleted file mode 100644 index dfd01c2c..00000000 --- a/lib/provider/history/state.g.dart +++ /dev/null @@ -1,55 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'state.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$PlaybackHistoryPlaylistImpl _$$PlaybackHistoryPlaylistImplFromJson( - Map json) => - _$PlaybackHistoryPlaylistImpl( - date: DateTime.parse(json['date'] as String), - playlist: PlaylistSimple.fromJson( - Map.from(json['playlist'] as Map)), - $type: json['runtimeType'] as String?, - ); - -Map _$$PlaybackHistoryPlaylistImplToJson( - _$PlaybackHistoryPlaylistImpl instance) => - { - 'date': instance.date.toIso8601String(), - 'playlist': instance.playlist.toJson(), - 'runtimeType': instance.$type, - }; - -_$PlaybackHistoryAlbumImpl _$$PlaybackHistoryAlbumImplFromJson(Map json) => - _$PlaybackHistoryAlbumImpl( - date: DateTime.parse(json['date'] as String), - album: - AlbumSimple.fromJson(Map.from(json['album'] as Map)), - $type: json['runtimeType'] as String?, - ); - -Map _$$PlaybackHistoryAlbumImplToJson( - _$PlaybackHistoryAlbumImpl instance) => - { - 'date': instance.date.toIso8601String(), - 'album': instance.album.toJson(), - 'runtimeType': instance.$type, - }; - -_$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) => - _$PlaybackHistoryTrackImpl( - date: DateTime.parse(json['date'] as String), - track: Track.fromJson(Map.from(json['track'] as Map)), - $type: json['runtimeType'] as String?, - ); - -Map _$$PlaybackHistoryTrackImplToJson( - _$PlaybackHistoryTrackImpl instance) => - { - 'date': instance.date.toIso8601String(), - 'track': instance.track.toJson(), - 'runtimeType': instance.$type, - }; diff --git a/lib/provider/history/summary.dart b/lib/provider/history/summary.dart deleted file mode 100644 index 2aa86ac9..00000000 --- a/lib/provider/history/summary.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/history/top.dart'; - -final playbackHistorySummaryProvider = Provider((ref) { - final (:tracks, :albums, :playlists) = - ref.watch(playbackHistoryGroupedProvider); - - final totalDurationListened = tracks.fold( - Duration.zero, - (previousValue, element) => previousValue + element.track.duration!, - ); - - final totalTracksListened = tracks - .whereIndexed( - (i, track) => - i == tracks.lastIndexWhere((e) => e.track.id == track.track.id), - ) - .length; - - final artists = - tracks.map((e) => e.track.artists).expand((e) => e ?? []).toList(); - - final totalArtistsListened = artists - .whereIndexed( - (i, artist) => i == artists.lastIndexWhere((e) => e.id == artist.id), - ) - .length; - - final totalAlbumsListened = albums - .whereIndexed( - (i, album) => - i == albums.lastIndexWhere((e) => e.album.id == album.album.id), - ) - .length; - - final totalPlaylistsListened = playlists - .whereIndexed( - (i, playlist) => - i == - playlists - .lastIndexWhere((e) => e.playlist.id == playlist.playlist.id), - ) - .length; - - final tracksThisMonth = ref.watch( - playbackHistoryTopProvider(HistoryDuration.days30).select((s) => s.tracks), - ); - - final streams = tracksThisMonth.fold(0, (acc, el) => acc + el.count); - - return ( - duration: totalDurationListened, - tracks: totalTracksListened, - artists: totalArtistsListened, - fees: streams * 0.005, // Spotify pays $0.003 to $0.005 - albums: totalAlbumsListened, - playlists: totalPlaylistsListened, - ); -}); diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart deleted file mode 100644 index 7d4594f0..00000000 --- a/lib/provider/history/top.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/history/state.dart'; - -final playbackHistoryTopDurationProvider = - StateProvider((ref) => HistoryDuration.days30); - -final playbackHistoryTopProvider = - Provider.family((ref, HistoryDuration durationState) { - final grouped = ref.watch(playbackHistoryGroupedProvider); - - final duration = switch (durationState) { - HistoryDuration.allTime => const Duration(days: 365 * 2003), - HistoryDuration.days7 => const Duration(days: 7), - HistoryDuration.days30 => const Duration(days: 30), - HistoryDuration.months6 => const Duration(days: 30 * 6), - HistoryDuration.year => const Duration(days: 365), - HistoryDuration.years2 => const Duration(days: 365 * 2), - }; - final tracks = grouped.tracks - .where( - (item) => item.date.isAfter( - DateTime.now().subtract(duration), - ), - ) - .toList(); - final albums = grouped.albums - .where( - (item) => item.date.isAfter( - DateTime.now().subtract(duration), - ), - ) - .toList(); - - final playlists = grouped.playlists - .where( - (item) => item.date.isAfter( - DateTime.now().subtract(duration), - ), - ) - .toList(); - - final tracksWithCount = groupBy( - tracks, - (track) => track.track.id!, - ) - .entries - .map((entry) { - return (count: entry.value.length, track: entry.value.first.track); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - - final albumsWithTrackAlbums = [ - for (final historicAlbum in albums) historicAlbum.album, - for (final track in tracks) track.track.album! - ]; - - final albumsWithCount = groupBy(albumsWithTrackAlbums, (album) => album.id!) - .entries - .map((entry) { - return (count: entry.value.length, album: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - - final artists = - tracks.map((track) => track.track.artists).expand((e) => e ?? []); - - final artistsWithCount = groupBy(artists, (artist) => artist.id!) - .entries - .map((entry) { - return (count: entry.value.length, artist: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - - final playlistsWithCount = - groupBy(playlists, (playlist) => playlist.playlist.id!) - .entries - .map((entry) { - return (count: entry.value.length, playlist: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - - return ( - tracks: tracksWithCount, - albums: albumsWithCount, - artists: artistsWithCount, - playlists: playlistsWithCount, - ); -}); diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart deleted file mode 100644 index 867774bd..00000000 --- a/lib/provider/local_tracks/local_tracks_provider.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'dart:io'; - -import 'package:catcher_2/catcher_2.dart'; -import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -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:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -// ignore: depend_on_referenced_packages -import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; - -const supportedAudioTypes = [ - "audio/webm", - "audio/ogg", - "audio/mpeg", - "audio/mp4", - "audio/opus", - "audio/wav", - "audio/aac", -]; - -const imgMimeToExt = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/webp": ".webp", - "image/gif": ".gif", -}; - -final localTracksProvider = - FutureProvider>>((ref) async { - try { - if (kIsWeb) return {}; - final Map> tracks = {}; - - final downloadLocation = ref.watch( - userPreferencesProvider.select((s) => s.downloadLocation), - ); - final downloadDir = Directory(downloadLocation); - if (!await downloadDir.exists()) { - await downloadDir.create(recursive: true); - } - final localLibraryLocations = ref.watch( - userPreferencesProvider.select((s) => s.localLibraryLocation), - ); - - for (var location in [downloadLocation, ...localLibraryLocations]) { - if (location.isEmpty) continue; - final entities = []; - if (await Directory(location).exists()) { - try { - entities.addAll(Directory(location).listSync(recursive: true)); - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - } - } - - final filesWithMetadata = (await Future.wait( - entities.map((e) => File(e.path)).where((file) { - final mimetype = lookupMimeType(file.path); - return mimetype != null && supportedAudioTypes.contains(mimetype); - }).map( - (file) async { - try { - final metadata = await MetadataGod.readMetadata(file: file.path); - - final imageFile = File(join( - (await getTemporaryDirectory()).path, - "spotube", - basenameWithoutExtension(file.path) + - imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, - )); - if (!await imageFile.exists() && metadata.picture != null) { - await imageFile.create(recursive: true); - await imageFile.writeAsBytes( - metadata.picture?.data ?? [], - mode: FileMode.writeOnly, - ); - } - - return { - "metadata": metadata, - "file": file, - "art": imageFile.path - }; - } catch (e, stack) { - if (e is FfiException) { - return {"file": file}; - } - Catcher2.reportCheckedError(e, stack); - return {}; - } - }, - ), - )) - .where((e) => e.isNotEmpty) - .toList(); - - // ignore: no_leading_underscores_for_local_identifiers - final _tracks = filesWithMetadata - .map( - (fileWithMetadata) => LocalTrack.fromTrack( - track: Track().fromFile( - fileWithMetadata["file"], - metadata: fileWithMetadata["metadata"], - art: fileWithMetadata["art"], - ), - path: fileWithMetadata["file"].path, - ), - ) - .toList(); - - tracks[location] = _tracks; - } - return tracks; - } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); - return {}; - } -}); diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index 3ee815e6..f86ad3d4 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -1,52 +1,26 @@ -// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +// ignore_for_file: invalid_use_of_protected_member import 'dart:async'; import 'package:catcher_2/catcher_2.dart'; -import 'package:palette_generator/palette_generator.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/skip_segments.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; extension ProxyPlaylistListeners on ProxyPlaylistNotifier { - Future updatePalette() async { - final palette = ref.read(paletteProvider); - if (!preferences.albumColorSync) { - if (palette != null) ref.read(paletteProvider.notifier).state = null; - return; - } - return Future.microtask(() async { - if (playlist.activeTrack == null) return; - - final palette = await PaletteGenerator.fromImageProvider( - UniversalImage.imageProvider( - (playlist.activeTrack?.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - height: 50, - width: 50, - ), - ); - ref.read(paletteProvider.notifier).state = palette; - }); - } - StreamSubscription subscribeToPlaylist() { - return audioPlayer.playlistStream.listen((mpvPlaylist) { - state = playlist.copyWith( - tracks: mpvPlaylist.medias + return audioPlayer.playlistStream.listen((playlist) { + state = state.copyWith( + tracks: playlist.medias .map((media) => SpotubeMedia.fromMedia(media).track) .toSet(), - active: mpvPlaylist.index, + active: playlist.index, ); - notificationService.addTrack(playlist.activeTrack!); - discord.updatePresence(playlist.activeTrack!); + notificationService.addTrack(state.activeTrack!); + discord.updatePresence(state.activeTrack!); updatePalette(); }); } @@ -72,18 +46,17 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { String? lastScrobbled; return audioPlayer.positionStream.listen((position) { try { - final uid = playlist.activeTrack is LocalTrack - ? (playlist.activeTrack as LocalTrack).path - : playlist.activeTrack?.id; + final uid = state.activeTrack is LocalTrack + ? (state.activeTrack as LocalTrack).path + : state.activeTrack?.id; - if (playlist.activeTrack == null || + if (state.activeTrack == null || lastScrobbled == uid || position.inSeconds < 30) { return; } - scrobbler.scrobble(playlist.activeTrack!); - history.addTrack(playlist.activeTrack!); + scrobbler.scrobble(state.activeTrack!); lastScrobbled = uid; } catch (e, stack) { Catcher2.reportCheckedError(e, stack); @@ -95,9 +68,9 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { String lastTrack = ""; // used to prevent multiple calls to the same track return audioPlayer.positionStream.listen((event) async { if (event < const Duration(seconds: 3) || - playlist.active == null || - playlist.active == playlist.tracks.length - 1) return; - final nextTrack = playlist.tracks.elementAt(playlist.active! + 1); + state.active == null || + state.active == state.tracks.length - 1) return; + final nextTrack = state.tracks.elementAt(state.active! + 1); if (lastTrack == nextTrack.id || nextTrack is LocalTrack) return; diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart index 9f371b7a..f70301ff 100644 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ b/lib/provider/proxy_playlist/proxy_playlist.dart @@ -1,5 +1,6 @@ import 'package:collection/collection.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -44,14 +45,7 @@ class ProxyPlaylist { } bool containsTrack(TrackSimple track) { - return tracks.firstWhereOrNull((element) { - if (element is LocalTrack && track is LocalTrack) { - return element.path == track.path; - } - - return element.id == track.id; - }) != - null; + return tracks.firstWhereOrNull((element) => element.id == track.id) != null; } bool containsTracks(Iterable tracks) { @@ -70,11 +64,9 @@ class ProxyPlaylist { /// To make sure proper instance method is used for JSON serialization /// Otherwise default super.toJson() is used static Map _makeAppropriateTrackJson(Track track) { - return switch (track) { - // ignore: unnecessary_cast - LocalTrack() => (track as LocalTrack).toJson(), - // ignore: unnecessary_cast - SourcedTrack() => (track as SourcedTrack).toJson(), + return switch (track.runtimeType) { + LocalTrack() => track.toJson(), + SourcedTrack() => track.toJson(), _ => track.toJson(), }; } diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index c8eb3657..9811a1f8 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -2,12 +2,14 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; @@ -30,8 +32,6 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { ProxyPlaylist get playlist => state; BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier); Discord get discord => ref.read(discordProvider); - PlaybackHistoryNotifier get history => - ref.read(playbackHistoryProvider.notifier); List _subscriptions = []; @@ -167,6 +167,28 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier { discord.clear(); } + Future updatePalette() async { + final palette = ref.read(paletteProvider); + if (!preferences.albumColorSync) { + if (palette != null) ref.read(paletteProvider.notifier).state = null; + return; + } + return Future.microtask(() async { + if (state.activeTrack == null) return; + + final palette = await PaletteGenerator.fromImageProvider( + UniversalImage.imageProvider( + (state.activeTrack?.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + height: 50, + width: 50, + ), + ); + ref.read(paletteProvider.notifier).state = palette; + }); + } + @override set state(state) { super.state = state; diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart index 7f3d1e9a..2d90eea6 100644 --- a/lib/provider/proxy_playlist/skip_segments.dart +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -1,11 +1,12 @@ +import 'dart:convert'; + import 'package:catcher_2/catcher_2.dart'; -import 'package:dio/dio.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; -import 'package:spotube/services/dio/dio.dart'; class SourcedSegments { final String source; @@ -29,35 +30,29 @@ Future> getAndCacheSkipSegments(String id) async { ); } - final res = await globalDio.getUri( - Uri( - scheme: "https", - host: "sponsor.ajay.app", - path: "/api/skipSegments", - queryParameters: { - "videoID": id, - "category": [ - 'sponsor', - 'selfpromo', - 'interaction', - 'intro', - 'outro', - 'music_offtopic' - ], - "actionType": 'skip' - }, - ), - options: Options( - responseType: ResponseType.json, - validateStatus: (status) => (status ?? 0) < 500, - ), - ); + final res = await get(Uri( + scheme: "https", + host: "sponsor.ajay.app", + path: "/api/skipSegments", + queryParameters: { + "videoID": id, + "category": [ + 'sponsor', + 'selfpromo', + 'interaction', + 'intro', + 'outro', + 'music_offtopic' + ], + "actionType": 'skip' + }, + )); - if (res.data == "Not Found") { + if (res.body == "Not Found") { return List.castFrom([]); } - final data = res.data as List; + final data = jsonDecode(res.body) as List; final segments = data.map((obj) { final start = obj["segment"].first.toInt(); final end = obj["segment"].last.toInt(); diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index 04a2ddca..6ce74ae7 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -9,34 +9,29 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier Track get _track => arg!; Future getSpotifyLyrics(String? token) async { - final res = await globalDio.getUri( - Uri.parse( - "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", - ), - options: Options( + final res = await http.get( + Uri.parse( + "https://spclient.wg.spotify.com/color-lyrics/v2/track/${_track.id}?format=json&market=from_token", + ), headers: { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.0.0 Safari/537.36", "App-platform": "WebPlayer", "authorization": "Bearer $token" - }, - responseType: ResponseType.json, - validateStatus: (status) => true, - ), - ); + }); if (res.statusCode != 200) { return SubtitleSimple( lyrics: [], name: _track.name!, - uri: res.realUri, + uri: res.request!.url, rating: 0, provider: "Spotify", ); } - final linesRaw = - Map.castFrom(res.data)["lyrics"] - ?["lines"] as List?; + final linesRaw = Map.castFrom( + jsonDecode(res.body), + )["lyrics"]?["lines"] as List?; final lines = linesRaw?.map((line) { return LyricSlice( @@ -49,7 +44,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: lines, name: _track.name!, - uri: res.realUri, + uri: res.request!.url, rating: 100, provider: "Spotify", ); @@ -60,7 +55,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier Future getLRCLibLyrics() async { final packageInfo = await PackageInfo.fromPlatform(); - final res = await globalDio.getUri( + final res = await http.get( Uri( scheme: "https", host: "lrclib.net", @@ -72,26 +67,23 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier "duration": _track.duration?.inSeconds.toString(), }, ), - options: Options( - headers: { - "User-Agent": - "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" - }, - responseType: ResponseType.json, - ), + headers: { + "User-Agent": + "Spotube v${packageInfo.version} (https://github.com/KRTirtho/spotube)" + }, ); if (res.statusCode != 200) { return SubtitleSimple( lyrics: [], name: _track.name!, - uri: res.realUri, + uri: res.request!.url, rating: 0, provider: "LRCLib", ); } - final json = res.data as Map; + final json = jsonDecode(res.body) as Map; final syncedLyricsRaw = json["syncedLyrics"] as String?; final syncedLyrics = syncedLyricsRaw?.isNotEmpty == true @@ -105,7 +97,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: syncedLyrics!, name: _track.name!, - uri: res.realUri, + uri: res.request!.url, rating: 100, provider: "LRCLib", ); @@ -119,7 +111,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return SubtitleSimple( lyrics: plainLyrics, name: _track.name!, - uri: res.realUri, + uri: res.request!.url, rating: 0, provider: "LRCLib", ); @@ -135,7 +127,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier final token = await spotify.getCredentials(); SubtitleSimple lyrics = await getSpotifyLyrics(token.accessToken); - if (lyrics.lyrics.isEmpty || lyrics.lyrics.length <= 5) { + if (lyrics.lyrics.isEmpty) { lyrics = await getLRCLibLyrics(); } diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index ac83ba72..816420f6 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -1,10 +1,10 @@ library spotify; import 'dart:async'; +import 'dart:convert'; import 'package:catcher_2/catcher_2.dart'; import 'package:collection/collection.dart'; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:intl/intl.dart'; @@ -23,9 +23,9 @@ import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/wikipedia/wikipedia.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:http/http.dart' as http; import 'package:wikipedia_api/wikipedia_api.dart'; diff --git a/lib/provider/tray_manager/tray_manager.dart b/lib/provider/tray_manager/tray_manager.dart deleted file mode 100644 index 2145cbef..00000000 --- a/lib/provider/tray_manager/tray_manager.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/tray_manager/tray_menu.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:tray_manager/tray_manager.dart'; -import 'package:window_manager/window_manager.dart'; - -class SystemTrayManager with TrayListener { - final Ref ref; - final bool enabled; - - SystemTrayManager( - this.ref, { - required this.enabled, - }) { - initialize(); - } - - Future initialize() async { - if (!kIsDesktop) return; - - if (enabled) { - await trayManager.setIcon( - kIsWindows - ? 'assets/spotube-logo.ico' - : kIsFlatpak - ? 'com.github.KRTirtho.Spotube.png' - : 'assets/spotube-logo.png', - ); - trayManager.addListener(this); - } else { - await trayManager.destroy(); - } - } - - void dispose() { - trayManager.removeListener(this); - } - - @override - onTrayIconMouseDown() { - if (kIsWindows) { - windowManager.show(); - } else { - trayManager.popUpContextMenu(); - } - } - - @override - onTrayIconRightMouseDown() { - if (!kIsWindows) { - windowManager.show(); - } else { - trayManager.popUpContextMenu(); - } - } -} - -final trayManagerProvider = Provider( - (ref) { - final enabled = ref.watch( - userPreferencesProvider.select((s) => s.showSystemTrayIcon), - ); - - ref.listen(trayMenuProvider, (_, menu) { - if (!enabled || !kIsDesktop) return; - trayManager.setContextMenu(menu); - }); - - final manager = SystemTrayManager( - ref, - enabled: enabled, - ); - - ref.onDispose(manager.dispose); - - return manager; - }, -); diff --git a/lib/provider/tray_manager/tray_menu.dart b/lib/provider/tray_manager/tray_menu.dart deleted file mode 100644 index cb793707..00000000 --- a/lib/provider/tray_manager/tray_menu.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:io'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; -import 'package:tray_manager/tray_manager.dart'; -import 'package:window_manager/window_manager.dart'; - -final audioPlayerLoopMode = StreamProvider((ref) { - return audioPlayer.loopModeStream; -}); - -final audioPlayerShuffleMode = StreamProvider((ref) { - return audioPlayer.shuffledStream; -}); -final audioPlayerPlaying = StreamProvider((ref) { - return audioPlayer.playingStream; -}); - -final trayMenuProvider = Provider((ref) { - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); - final isPlaybackPlaying = - ref.watch(proxyPlaylistProvider.select((s) => s.activeTrack != null)); - final isLoopOne = - ref.watch(audioPlayerLoopMode).asData?.value == PlaybackLoopMode.one; - final isShuffled = ref.watch(audioPlayerShuffleMode).asData?.value ?? false; - final isPlaying = ref.watch(audioPlayerPlaying).asData?.value ?? false; - - return Menu( - items: [ - MenuItem( - label: "Show/Hide Window", - onClick: (menuItem) async { - if (await windowManager.isVisible()) { - await windowManager.hide(); - } else { - await windowManager.focus(); - await windowManager.show(); - } - }, - ), - MenuItem.separator(), - MenuItem( - label: isPlaying ? "Pause" : "Play", - disabled: !isPlaybackPlaying, - onClick: (menuItem) async { - if (audioPlayer.isPlaying) { - await audioPlayer.pause(); - } else { - await audioPlayer.resume(); - } - }, - ), - MenuItem( - label: "Next", - disabled: !isPlaybackPlaying, - onClick: (menuItem) { - playlistNotifier.next(); - }, - ), - MenuItem( - label: "Previous", - disabled: !isPlaybackPlaying, - onClick: (menuItem) { - playlistNotifier.previous(); - }, - ), - MenuItem.submenu( - label: "Playback", - submenu: Menu( - items: [ - MenuItem( - label: "Repeat", - checked: isLoopOne, - onClick: (menuItem) { - audioPlayer.setLoopMode( - isLoopOne ? PlaybackLoopMode.none : PlaybackLoopMode.one, - ); - }, - ), - MenuItem( - label: "Shuffle", - checked: isShuffled, - onClick: (menuItem) { - audioPlayer.setShuffle(!isShuffled); - }, - ), - MenuItem.separator(), - MenuItem( - label: "Stop", - onClick: (menuItem) { - playlistNotifier.stop(); - }, - ), - ], - ), - ), - MenuItem.separator(), - MenuItem( - label: "Quit", - onClick: (menuItem) { - exit(0); - }, - ), - ], - ); -}); diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index fe726915..a1e247b2 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -1,12 +1,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -15,7 +15,6 @@ import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; import 'package:path/path.dart' as path; -import 'package:window_manager/window_manager.dart'; class UserPreferencesNotifier extends PersistedStateNotifier { final Ref ref; @@ -70,11 +69,6 @@ class UserPreferencesNotifier extends PersistedStateNotifier { state = state.copyWith(downloadLocation: downloadDir); } - void setLocalLibraryLocation(List localLibraryDirs) { - //if (localLibraryDir.isEmpty) return; - state = state.copyWith(localLibraryLocation: localLibraryDirs); - } - void setLayoutMode(LayoutMode mode) { state = state.copyWith(layoutMode: mode); } @@ -109,8 +103,8 @@ class UserPreferencesNotifier extends PersistedStateNotifier { void setSystemTitleBar(bool isSystemTitleBar) { state = state.copyWith(systemTitleBar: isSystemTitleBar); - if (kIsDesktop) { - windowManager.setTitleBarStyle( + if (DesktopTools.platform.isDesktop) { + DesktopTools.window.setTitleBarStyle( isSystemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, ); } @@ -157,8 +151,8 @@ class UserPreferencesNotifier extends PersistedStateNotifier { ); } - if (kIsDesktop) { - await windowManager.setTitleBarStyle( + if (DesktopTools.platform.isDesktop) { + await DesktopTools.window.setTitleBarStyle( state.systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, ); } diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index 56f66375..e35c73b5 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -62,10 +62,10 @@ class UserPreferences with _$UserPreferences { @Default(false) bool amoledDarkTheme, @Default(true) bool checkUpdate, @Default(false) bool normalizeAudio, - @Default(false) bool showSystemTrayIcon, + @Default(true) bool showSystemTrayIcon, @Default(false) bool skipNonMusic, @Default(false) bool systemTitleBar, - @Default(CloseBehavior.close) CloseBehavior closeBehavior, + @Default(CloseBehavior.minimizeToTray) CloseBehavior closeBehavior, @Default(SpotubeColor(0xFF2196F3, name: "Blue")) @JsonKey( fromJson: UserPreferences._accentColorSchemeFromJson, @@ -84,7 +84,6 @@ class UserPreferences with _$UserPreferences { @Default(Market.US) Market recommendationMarket, @Default(SearchMode.youtube) SearchMode searchMode, @Default("") String downloadLocation, - @Default([]) List localLibraryLocation, @Default("https://pipedapi.kavin.rocks") String pipedInstance, @Default(ThemeMode.system) ThemeMode themeMode, @Default(AudioSource.youtube) AudioSource audioSource, diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart index 89c7210a..a5b076bb 100644 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ b/lib/provider/user_preferences/user_preferences_state.freezed.dart @@ -12,7 +12,7 @@ part of 'user_preferences_state.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); UserPreferences _$UserPreferencesFromJson(Map json) { return _UserPreferences.fromJson(json); @@ -43,7 +43,6 @@ mixin _$UserPreferences { Market get recommendationMarket => throw _privateConstructorUsedError; SearchMode get searchMode => throw _privateConstructorUsedError; String get downloadLocation => throw _privateConstructorUsedError; - List get localLibraryLocation => throw _privateConstructorUsedError; String get pipedInstance => throw _privateConstructorUsedError; ThemeMode get themeMode => throw _privateConstructorUsedError; AudioSource get audioSource => throw _privateConstructorUsedError; @@ -89,7 +88,6 @@ abstract class $UserPreferencesCopyWith<$Res> { Market recommendationMarket, SearchMode searchMode, String downloadLocation, - List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, @@ -128,7 +126,6 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> Object? recommendationMarket = null, Object? searchMode = null, Object? downloadLocation = null, - Object? localLibraryLocation = null, Object? pipedInstance = null, Object? themeMode = null, Object? audioSource = null, @@ -199,10 +196,6 @@ class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> ? _value.downloadLocation : downloadLocation // ignore: cast_nullable_to_non_nullable as String, - localLibraryLocation: null == localLibraryLocation - ? _value.localLibraryLocation - : localLibraryLocation // ignore: cast_nullable_to_non_nullable - as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -271,7 +264,6 @@ abstract class _$$UserPreferencesImplCopyWith<$Res> Market recommendationMarket, SearchMode searchMode, String downloadLocation, - List localLibraryLocation, String pipedInstance, ThemeMode themeMode, AudioSource audioSource, @@ -308,7 +300,6 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> Object? recommendationMarket = null, Object? searchMode = null, Object? downloadLocation = null, - Object? localLibraryLocation = null, Object? pipedInstance = null, Object? themeMode = null, Object? audioSource = null, @@ -379,10 +370,6 @@ class __$$UserPreferencesImplCopyWithImpl<$Res> ? _value.downloadLocation : downloadLocation // ignore: cast_nullable_to_non_nullable as String, - localLibraryLocation: null == localLibraryLocation - ? _value._localLibraryLocation - : localLibraryLocation // ignore: cast_nullable_to_non_nullable - as List, pipedInstance: null == pipedInstance ? _value.pipedInstance : pipedInstance // ignore: cast_nullable_to_non_nullable @@ -428,10 +415,10 @@ class _$UserPreferencesImpl implements _UserPreferences { this.amoledDarkTheme = false, this.checkUpdate = true, this.normalizeAudio = false, - this.showSystemTrayIcon = false, + this.showSystemTrayIcon = true, this.skipNonMusic = false, this.systemTitleBar = false, - this.closeBehavior = CloseBehavior.close, + this.closeBehavior = CloseBehavior.minimizeToTray, @JsonKey( fromJson: UserPreferences._accentColorSchemeFromJson, toJson: UserPreferences._accentColorSchemeToJson, @@ -446,7 +433,6 @@ class _$UserPreferencesImpl implements _UserPreferences { this.recommendationMarket = Market.US, this.searchMode = SearchMode.youtube, this.downloadLocation = "", - final List localLibraryLocation = const [], this.pipedInstance = "https://pipedapi.kavin.rocks", this.themeMode = ThemeMode.system, this.audioSource = AudioSource.youtube, @@ -454,8 +440,7 @@ class _$UserPreferencesImpl implements _UserPreferences { this.downloadMusicCodec = SourceCodecs.m4a, this.discordPresence = true, this.endlessPlayback = true, - this.enableConnect = false}) - : _localLibraryLocation = localLibraryLocation; + this.enableConnect = false}); factory _$UserPreferencesImpl.fromJson(Map json) => _$$UserPreferencesImplFromJson(json); @@ -511,16 +496,6 @@ class _$UserPreferencesImpl implements _UserPreferences { @override @JsonKey() final String downloadLocation; - final List _localLibraryLocation; - @override - @JsonKey() - List get localLibraryLocation { - if (_localLibraryLocation is EqualUnmodifiableListView) - return _localLibraryLocation; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_localLibraryLocation); - } - @override @JsonKey() final String pipedInstance; @@ -548,7 +523,7 @@ class _$UserPreferencesImpl implements _UserPreferences { @override String toString() { - return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, localLibraryLocation: $localLibraryLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; + return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; } @override @@ -585,8 +560,6 @@ class _$UserPreferencesImpl implements _UserPreferences { other.searchMode == searchMode) && (identical(other.downloadLocation, downloadLocation) || other.downloadLocation == downloadLocation) && - const DeepCollectionEquality() - .equals(other._localLibraryLocation, _localLibraryLocation) && (identical(other.pipedInstance, pipedInstance) || other.pipedInstance == pipedInstance) && (identical(other.themeMode, themeMode) || @@ -624,7 +597,6 @@ class _$UserPreferencesImpl implements _UserPreferences { recommendationMarket, searchMode, downloadLocation, - const DeepCollectionEquality().hash(_localLibraryLocation), pipedInstance, themeMode, audioSource, @@ -675,7 +647,6 @@ abstract class _UserPreferences implements UserPreferences { final Market recommendationMarket, final SearchMode searchMode, final String downloadLocation, - final List localLibraryLocation, final String pipedInstance, final ThemeMode themeMode, final AudioSource audioSource, @@ -727,8 +698,6 @@ abstract class _UserPreferences implements UserPreferences { @override String get downloadLocation; @override - List get localLibraryLocation; - @override String get pipedInstance; @override ThemeMode get themeMode; diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart index 4bcb3a46..8bdd12cc 100644 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ b/lib/provider/user_preferences/user_preferences_state.g.dart @@ -6,7 +6,8 @@ part of 'user_preferences_state.dart'; // JsonSerializableGenerator // ************************************************************************** -_$UserPreferencesImpl _$$UserPreferencesImplFromJson(Map json) => +_$UserPreferencesImpl _$$UserPreferencesImplFromJson( + Map json) => _$UserPreferencesImpl( audioQuality: $enumDecodeNullable(_$SourceQualitiesEnumMap, json['audioQuality']) ?? @@ -15,12 +16,12 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson(Map json) => amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, checkUpdate: json['checkUpdate'] as bool? ?? true, normalizeAudio: json['normalizeAudio'] as bool? ?? false, - showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? false, + showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? true, skipNonMusic: json['skipNonMusic'] as bool? ?? false, systemTitleBar: json['systemTitleBar'] as bool? ?? false, closeBehavior: $enumDecodeNullable(_$CloseBehaviorEnumMap, json['closeBehavior']) ?? - CloseBehavior.close, + CloseBehavior.minimizeToTray, accentColorScheme: UserPreferences._accentColorSchemeReadValue( json, 'accentColorScheme') == null @@ -43,10 +44,6 @@ _$UserPreferencesImpl _$$UserPreferencesImplFromJson(Map json) => $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? SearchMode.youtube, downloadLocation: json['downloadLocation'] as String? ?? "", - localLibraryLocation: (json['localLibraryLocation'] as List?) - ?.map((e) => e as String) - .toList() ?? - const [], pipedInstance: json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks", themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? @@ -84,7 +81,6 @@ Map _$$UserPreferencesImplToJson( 'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!, 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, 'downloadLocation': instance.downloadLocation, - 'localLibraryLocation': instance.localLibraryLocation, 'pipedInstance': instance.pipedInstance, 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 8d3e0bfb..a81c6c95 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:catcher_2/catcher_2.dart'; import 'package:flutter/foundation.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/services/audio_player/custom_player.dart'; @@ -12,7 +13,6 @@ import 'package:media_kit/media_kit.dart' as mk; import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; @@ -30,18 +30,12 @@ class SpotubeMedia extends mk.Media { : "http://${InternetAddress.loopbackIPv4.address}:${PlaybackServer.port}/stream/${track.id}", extras: { ...?extras, - "track": switch (track) { - LocalTrack() => track.toJson(), - SourcedTrack() => track.toJson(), - _ => track.toJson(), - }, + "track": track.toJson(), }, ); factory SpotubeMedia.fromMedia(mk.Media media) { - final track = media.uri.startsWith("http") - ? Track.fromJson(media.extras?["track"]) - : LocalTrack.fromJson(media.extras?["track"]); + final track = Track.fromJson(media.extras?["track"]); return SpotubeMedia(track); } } @@ -107,7 +101,7 @@ abstract class AudioPlayerInterface { return _mkPlayer.state.completed; } - bool get isShuffled { + Future get isShuffled async { return _mkPlayer.shuffled; } diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart index e32a0d14..916a983f 100644 --- a/lib/services/audio_player/custom_player.dart +++ b/lib/services/audio_player/custom_player.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:catcher_2/catcher_2.dart'; import 'package:media_kit/media_kit.dart'; import 'package:flutter_broadcasts/flutter_broadcasts.dart'; @@ -6,7 +7,6 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:audio_session/audio_session.dart'; // ignore: implementation_imports import 'package:spotube/services/audio_player/playback_state.dart'; -import 'package:spotube/utils/platform.dart'; /// MediaKit [Player] by default doesn't have a state stream. /// This class adds a state stream to the [Player] class. @@ -54,7 +54,7 @@ class CustomPlayer extends Player { PackageInfo.fromPlatform().then((packageInfo) { _packageName = packageInfo.packageName; }); - if (kIsAndroid) { + if (DesktopTools.platform.isAndroid) { _androidAudioManager = AndroidAudioManager(); AudioSession.instance.then((s) async { _androidAudioSessionId = @@ -71,7 +71,7 @@ class CustomPlayer extends Player { } Future notifyAudioSessionUpdate(bool active) async { - if (kIsAndroid) { + if (DesktopTools.platform.isAndroid) { sendBroadcast( BroadcastMessage( name: active diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index f42d6c4b..338427aa 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -1,4 +1,5 @@ import 'package:audio_service/audio_service.dart'; +import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/artist_simple.dart'; @@ -7,7 +8,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/platform.dart'; class AudioServices { final MobileAudioService? mobile; @@ -19,7 +19,9 @@ class AudioServices { Ref ref, ProxyPlaylistNotifier playback, ) async { - final mobile = kIsMobile || kIsMacOS || kIsLinux + final mobile = DesktopTools.platform.isMobile || + DesktopTools.platform.isMacOS || + DesktopTools.platform.isLinux ? await AudioService.init( builder: () => MobileAudioService(playback), config: const AudioServiceConfig( @@ -29,7 +31,9 @@ class AudioServices { ), ) : null; - final smtc = kIsWindows ? WindowsAudioService(ref, playback) : null; + final smtc = DesktopTools.platform.isWindows + ? WindowsAudioService(ref, playback) + : null; return AudioServices( mobile, diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 62cc8552..3bb88447 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -11,7 +11,7 @@ class MobileAudioService extends BaseAudioHandler { AudioSession? session; final ProxyPlaylistNotifier playlistNotifier; - // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member + // ignore: invalid_use_of_protected_member ProxyPlaylist get playlist => playlistNotifier.state; MobileAudioService(this.playlistNotifier) { @@ -135,7 +135,7 @@ class MobileAudioService extends BaseAudioHandler { playing: audioPlayer.isPlaying, updatePosition: position, bufferedPosition: await audioPlayer.bufferedPosition ?? Duration.zero, - shuffleMode: audioPlayer.isShuffled == true + shuffleMode: await audioPlayer.isShuffled == true ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, repeatMode: (audioPlayer.loopMode).toAudioServiceRepeatMode(), diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index 0c7daeb2..d8600366 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:dio/dio.dart'; +import 'package:http/http.dart' as http; import 'package:spotify/spotify.dart'; import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; @@ -9,21 +9,9 @@ import 'package:timezone/timezone.dart' as tz; class CustomSpotifyEndpoints { static const _baseUrl = 'https://api.spotify.com/v1'; final String accessToken; - final Dio _client; + final http.Client _client; - CustomSpotifyEndpoints(this.accessToken) - : _client = Dio( - BaseOptions( - baseUrl: _baseUrl, - responseType: ResponseType.json, - headers: { - "content-type": "application/json", - if (accessToken.isNotEmpty) - "authorization": "Bearer $accessToken", - "accept": "application/json", - }, - ), - ); + CustomSpotifyEndpoints(this.accessToken) : _client = http.Client(); // views API @@ -77,34 +65,44 @@ class CustomSpotifyEndpoints { if (country != null) 'country': country.name, }.entries.map((e) => '${e.key}=${e.value}').join('&'); - final res = await _client.getUri( + final res = await _client.get( Uri.parse('$_baseUrl/views/$view?$queryParams'), + headers: { + "content-type": "application/json", + "authorization": "Bearer $accessToken", + "accept": "application/json", + }, ); if (res.statusCode == 200) { - return res.data; + return jsonDecode(res.body); } else { throw Exception( '[CustomSpotifyEndpoints.getView]: Failed to get view' '\nStatus code: ${res.statusCode}' - '\nBody: ${res.data}', + '\nBody: ${res.body}', ); } } Future> listGenreSeeds() async { - final res = await _client.getUri( + final res = await _client.get( Uri.parse("$_baseUrl/recommendations/available-genre-seeds"), + headers: { + "content-type": "application/json", + if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", + "accept": "application/json", + }, ); if (res.statusCode == 200) { - final body = res.data; + final body = jsonDecode(res.body); return List.from(body["genres"] ?? []); } else { throw Exception( '[CustomSpotifyEndpoints.listGenreSeeds]: Failed to get genre seeds' '\nStatus code: ${res.statusCode}' - '\nBody: ${res.data}', + '\nBody: ${res.body}', ); } } @@ -154,18 +152,30 @@ class CustomSpotifyEndpoints { } final pathQuery = "$_baseUrl/recommendations?${parameters.entries.map((e) => '${e.key}=${e.value}').join('&')}"; - final res = await _client.getUri(Uri.parse(pathQuery)); - final result = res.data; + final res = await _client.get( + Uri.parse(pathQuery), + headers: { + "content-type": "application/json", + if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", + "accept": "application/json", + }, + ); + final result = jsonDecode(res.body); return List.castFrom( result["tracks"].map((track) => Track.fromJson(track)).toList(), ); } Future getFriendActivity() async { - final res = await _client.getUri( + final res = await _client.get( Uri.parse("https://guc-spclient.spotify.com/presence-view/v1/buddylist"), + headers: { + "content-type": "application/json", + "authorization": "Bearer $accessToken", + "accept": "application/json", + }, ); - return SpotifyFriends.fromJson(res.data); + return SpotifyFriends.fromJson(jsonDecode(res.body)); } Future getHomeFeed({ @@ -180,7 +190,7 @@ class CustomSpotifyEndpoints { 'origin': 'https://open.spotify.com', 'referer': 'https://open.spotify.com/' }; - final response = await _client.getUri( + final response = await http.get( Uri( scheme: "https", host: "api-partner.spotify.com", @@ -209,11 +219,21 @@ class CustomSpotifyEndpoints { ), }, ), - options: Options(headers: headers), + headers: headers, ); + if (response.statusCode >= 400) { + throw Exception( + "[RequestException] " + "Status: ${response.statusCode}\n" + "Body: ${response.body}", + ); + } + final data = SpotifyHomeFeed.fromJson( - transformHomeFeedJsonMap(response.data), + transformHomeFeedJsonMap( + jsonDecode(response.body), + ), ); return data; @@ -232,7 +252,7 @@ class CustomSpotifyEndpoints { 'origin': 'https://open.spotify.com', 'referer': 'https://open.spotify.com/' }; - final response = await _client.getUri( + final response = await http.get( Uri( scheme: "https", host: "api-partner.spotify.com", @@ -260,12 +280,20 @@ class CustomSpotifyEndpoints { ), }, ), - options: Options(headers: headers), + headers: headers, ); + if (response.statusCode >= 400) { + throw Exception( + "[RequestException] " + "Status: ${response.statusCode}\n" + "Body: ${response.body}", + ); + } + final data = SpotifyHomeFeedSection.fromJson( transformSectionItemJsonMap( - response.data["data"]["homeSections"]["sections"][0], + jsonDecode(response.body)["data"]["homeSections"]["sections"][0], ), ); diff --git a/lib/services/dio/dio.dart b/lib/services/dio/dio.dart deleted file mode 100644 index cddf1979..00000000 --- a/lib/services/dio/dio.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:dio/dio.dart'; - -final globalDio = Dio(); diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index ae62a055..f94ec4ee 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -1,7 +1,4 @@ -import 'dart:convert'; - import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/services/wm_tools/wm_tools.dart'; abstract class KVStoreService { static SharedPreferences? _sharedPreferences; @@ -26,21 +23,4 @@ abstract class KVStoreService { static Future setRecentSearches(List value) async => await sharedPreferences.setStringList('recentSearches', value); - - static WindowSize? get windowSize { - final raw = sharedPreferences.getString('windowSize'); - - if (raw == null) { - return null; - } - return WindowSize.fromJson(jsonDecode(raw)); - } - - static Future setWindowSize(WindowSize value) async => - await sharedPreferences.setString( - 'windowSize', - jsonEncode( - value.toJson(), - ), - ); } diff --git a/lib/services/song_link/song_link.freezed.dart b/lib/services/song_link/song_link.freezed.dart index 0a1af8a9..a8230eeb 100644 --- a/lib/services/song_link/song_link.freezed.dart +++ b/lib/services/song_link/song_link.freezed.dart @@ -12,7 +12,7 @@ part of 'song_link.dart'; T _$identity(T value) => value; final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); SongLink _$SongLinkFromJson(Map json) { return _SongLink.fromJson(json); diff --git a/lib/services/song_link/song_link.g.dart b/lib/services/song_link/song_link.g.dart index 7658a74c..911849e3 100644 --- a/lib/services/song_link/song_link.g.dart +++ b/lib/services/song_link/song_link.g.dart @@ -6,7 +6,8 @@ part of 'song_link.dart'; // JsonSerializableGenerator // ************************************************************************** -_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => _$SongLinkImpl( +_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => + _$SongLinkImpl( displayName: json['displayName'] as String, linkId: json['linkId'] as String, platform: json['platform'] as String, diff --git a/lib/services/sourced_track/models/source_info.g.dart b/lib/services/sourced_track/models/source_info.g.dart index 5fe136ce..1ec9f75f 100644 --- a/lib/services/sourced_track/models/source_info.g.dart +++ b/lib/services/sourced_track/models/source_info.g.dart @@ -6,7 +6,7 @@ part of 'source_info.dart'; // JsonSerializableGenerator // ************************************************************************** -SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( +SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo( id: json['id'] as String, title: json['title'] as String, artist: json['artist'] as String, diff --git a/lib/services/sourced_track/models/source_map.g.dart b/lib/services/sourced_track/models/source_map.g.dart index a581cc67..e1085aa8 100644 --- a/lib/services/sourced_track/models/source_map.g.dart +++ b/lib/services/sourced_track/models/source_map.g.dart @@ -6,7 +6,8 @@ part of 'source_map.dart'; // JsonSerializableGenerator // ************************************************************************** -SourceQualityMap _$SourceQualityMapFromJson(Map json) => SourceQualityMap( +SourceQualityMap _$SourceQualityMapFromJson(Map json) => + SourceQualityMap( high: json['high'] as String, medium: json['medium'] as String, low: json['low'] as String, @@ -19,18 +20,16 @@ Map _$SourceQualityMapToJson(SourceQualityMap instance) => 'low': instance.low, }; -SourceMap _$SourceMapFromJson(Map json) => SourceMap( +SourceMap _$SourceMapFromJson(Map json) => SourceMap( weba: json['weba'] == null ? null - : SourceQualityMap.fromJson( - Map.from(json['weba'] as Map)), + : SourceQualityMap.fromJson(json['weba'] as Map), m4a: json['m4a'] == null ? null - : SourceQualityMap.fromJson( - Map.from(json['m4a'] as Map)), + : SourceQualityMap.fromJson(json['m4a'] as Map), ); Map _$SourceMapToJson(SourceMap instance) => { - 'weba': instance.weba?.toJson(), - 'm4a': instance.m4a?.toJson(), + 'weba': instance.weba, + 'm4a': instance.m4a, }; diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index 7eedfad8..a5e094ed 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -135,10 +135,16 @@ abstract class SourcedTrack extends Track { return await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref); } catch (e) { if (e is DioException || e is ClientException || e is SocketException) { + if (preferences.audioSource == AudioSource.jiosaavn) { + return await JioSaavnSourcedTrack.fetchFromTrack( + track: track, + ref: ref, + weakMatch: true, + ); + } return await JioSaavnSourcedTrack.fetchFromTrack( track: track, ref: ref, - weakMatch: preferences.audioSource == AudioSource.jiosaavn, ); } rethrow; diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index 8444db53..75f83125 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -163,7 +163,7 @@ class PipedSourcedTrack extends SourcedTrack { final PipedSearchResult(items: searchResults) = await pipedClient.search( query, preference.searchMode == SearchMode.youtube - ? PipedFilter.video + ? PipedFilter.videos : PipedFilter.musicSongs, ); diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index c24edfc0..3fc78f0b 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -1,4 +1,3 @@ -import 'package:catcher_2/core/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart'; @@ -222,19 +221,14 @@ class YoutubeSourcedTrack extends SourcedTrack { final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube"); if (ytLink?.url != null) { - try { - return [ - await toSiblingType( - 0, - YoutubeVideoInfo.fromVideo( - await youtubeClient.videos.get(ytLink!.url!), - ), - ) - ]; - } on VideoUnplayableException catch (e, stack) { - // Ignore this error and continue with the search - Catcher2.reportCheckedError(e, stack); - } + return [ + await toSiblingType( + 0, + YoutubeVideoInfo.fromVideo( + await youtubeClient.videos.get(ytLink!.url!), + ), + ) + ]; } final query = SourcedTrack.getSearchTerm(track); diff --git a/lib/services/wm_tools/wm_tools.dart b/lib/services/wm_tools/wm_tools.dart deleted file mode 100644 index 4572a8b4..00000000 --- a/lib/services/wm_tools/wm_tools.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:window_manager/window_manager.dart'; - -class WindowSize { - final double height; - final double width; - final bool maximized; - - WindowSize({ - required this.height, - required this.width, - required this.maximized, - }); - - factory WindowSize.fromJson(Map json) => WindowSize( - height: json["height"], - width: json["width"], - maximized: json["maximized"], - ); - - Map toJson() => { - "height": height, - "width": width, - "maximized": maximized, - }; -} - -class WindowManagerTools with WidgetsBindingObserver { - static WindowManagerTools? _instance; - static WindowManagerTools get instance => _instance!; - - WindowManagerTools._(); - - static Future initialize() async { - await windowManager.ensureInitialized(); - _instance = WindowManagerTools._(); - WidgetsBinding.instance.addObserver(instance); - - await windowManager.waitUntilReadyToShow( - const WindowOptions( - title: "Spotube", - backgroundColor: Colors.transparent, - minimumSize: Size(300, 700), - titleBarStyle: TitleBarStyle.hidden, - ), - () async { - final savedSize = KVStoreService.windowSize; - await windowManager.setResizable(true); - if (savedSize?.maximized == true && - !(await windowManager.isMaximized())) { - await windowManager.maximize(); - } else if (savedSize != null) { - await windowManager.setSize(Size(savedSize.width, savedSize.height)); - } - - await windowManager.focus(); - await windowManager.show(); - }, - ); - } - - Size? _prevSize; - - @override - void didChangeMetrics() async { - super.didChangeMetrics(); - if (kIsMobile) return; - final size = await windowManager.getSize(); - final windowSameDimension = - _prevSize?.width == size.width && _prevSize?.height == size.height; - - if (windowSameDimension || _prevSize == null) { - _prevSize = size; - return; - } - final isMaximized = await windowManager.isMaximized(); - await KVStoreService.setWindowSize( - WindowSize( - height: size.height, - width: size.width, - maximized: isMaximized, - ), - ); - _prevSize = size; - } -} diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 28acc280..51e98269 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -4,32 +4,18 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { final scheme = ColorScheme.fromSeed( seedColor: seed, shadow: Colors.black12, + background: isAmoled ? Colors.black : null, surface: isAmoled ? Colors.black : null, - surfaceContainer: isAmoled ? const Color(0xFF090909) : null, - surfaceContainerHigh: isAmoled ? const Color(0xFF181818) : null, - surfaceContainerHighest: isAmoled ? const Color(0xFF282828) : null, brightness: brightness, ); return ThemeData( useMaterial3: true, colorScheme: scheme, - scaffoldBackgroundColor: isAmoled ? Colors.black : null, - cardTheme: CardTheme( - color: scheme.surfaceContainer, - shadowColor: scheme.shadow, - elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), - ), listTileTheme: ListTileThemeData( horizontalTitleGap: 5, iconColor: scheme.onSurface, ), - appBarTheme: const AppBarTheme( - surfaceTintColor: Colors.transparent, - scrolledUnderElevation: 0, - shadowColor: Colors.transparent, - elevation: 0, - ), + appBarTheme: const AppBarTheme(surfaceTintColor: Colors.transparent), inputDecorationTheme: InputDecorationTheme( border: OutlineInputBorder( borderRadius: BorderRadius.circular(15), @@ -39,7 +25,7 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { navigationBarTheme: const NavigationBarThemeData( labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, height: 50, - iconTheme: WidgetStatePropertyAll( + iconTheme: MaterialStatePropertyAll( IconThemeData(size: 18), ), ), @@ -66,25 +52,25 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { ), sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay), searchBarTheme: SearchBarThemeData( - textStyle: const WidgetStatePropertyAll(TextStyle(fontSize: 15)), + textStyle: const MaterialStatePropertyAll(TextStyle(fontSize: 15)), constraints: const BoxConstraints(maxWidth: double.infinity), - padding: const WidgetStatePropertyAll(EdgeInsets.all(8)), - backgroundColor: WidgetStatePropertyAll( + padding: const MaterialStatePropertyAll(EdgeInsets.all(8)), + backgroundColor: MaterialStatePropertyAll( Color.lerp( - scheme.surfaceContainerHighest, + scheme.surfaceVariant, scheme.surface, brightness == Brightness.light ? .9 : .7, ), ), - elevation: const WidgetStatePropertyAll(0), - shape: WidgetStatePropertyAll( + elevation: const MaterialStatePropertyAll(0), + shape: MaterialStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), ), scrollbarTheme: const ScrollbarThemeData( - thickness: WidgetStatePropertyAll(14), + thickness: MaterialStatePropertyAll(14), ), checkboxTheme: CheckboxThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index aa2cd985..88c52896 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -1,28 +1,19 @@ -import 'package:dio/dio.dart'; +import 'dart:convert'; + +import 'package:flutter/widgets.dart' hide Element; import 'package:go_router/go_router.dart'; -import 'package:html/dom.dart' hide Text; +import 'package:html/dom.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/library/user_local_tracks.dart'; -import 'package:spotube/components/root/update_dialog.dart'; import 'package:spotube/models/logger.dart'; +import 'package:http/http.dart' as http; import 'package:spotube/models/lyrics.dart'; -import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:collection/collection.dart'; import 'package:html/parser.dart' as parser; -import 'dart:async'; - -import 'package:flutter/material.dart' hide Element; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:spotube/collections/env.dart'; - -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:version/version.dart'; - abstract class ServiceUtils { static final logger = getLogger("ServiceUtils"); @@ -69,12 +60,9 @@ abstract class ServiceUtils { } static Future extractLyrics(Uri url) async { - final response = await globalDio.getUri( - url, - options: Options(responseType: ResponseType.plain), - ); + final response = await http.get(url); - Document document = parser.parse(response.data); + Document document = parser.parse(response.body); String? lyrics = document.querySelector('div.lyrics')?.text.trim(); if (lyrics == null) { lyrics = ""; @@ -113,14 +101,11 @@ abstract class ServiceUtils { String reqUrl = "$searchUrl${Uri.encodeComponent(song)}"; Map headers = {"Authorization": 'Bearer $apiKey'}; - final response = await globalDio.getUri( + final response = await http.get( Uri.parse(authHeader ? reqUrl : "$reqUrl&access_token=$apiKey"), - options: Options( - headers: authHeader ? headers : null, - responseType: ResponseType.json, - ), + headers: authHeader ? headers : null, ); - Map data = response.data["response"]; + Map data = jsonDecode(response.body)["response"]; if (data["hits"]?.length == 0) return null; List results = data["hits"]?.map((val) { return { @@ -200,11 +185,8 @@ abstract class ServiceUtils { queryParameters: {"q": query}, ); - final res = await globalDio.getUri( - searchUri, - options: Options(responseType: ResponseType.plain), - ); - final document = parser.parse(res.data); + final res = await http.get(searchUri); + final document = parser.parse(res.body); final results = document.querySelectorAll("#tablecontainer table tbody tr td a"); @@ -237,11 +219,7 @@ abstract class ServiceUtils { logger.v("[Selected subtitle] ${topResult.text} | $subtitleUri"); - final lrcDocument = parser.parse((await globalDio.getUri( - subtitleUri, - options: Options(responseType: ResponseType.plain), - )) - .data); + final lrcDocument = parser.parse((await http.get(subtitleUri)).body); final lrcList = lrcDocument .querySelector("#ctl00_ContentPlaceHolder1_lbllyrics") ?.innerHtml @@ -284,22 +262,6 @@ abstract class ServiceUtils { GoRouter.of(context).go(location, extra: extra); } - static void navigateNamed( - BuildContext context, - String name, { - Object? extra, - Map? pathParameters, - Map? queryParameters, - }) { - if (GoRouterState.of(context).matchedLocation == name) return; - GoRouter.of(context).goNamed( - name, - pathParameters: pathParameters ?? const {}, - queryParameters: queryParameters ?? const {}, - extra: extra, - ); - } - static void push(BuildContext context, String location, {Object? extra}) { final router = GoRouter.of(context); final routerState = GoRouterState.of(context); @@ -311,36 +273,6 @@ abstract class ServiceUtils { router.push(location, extra: extra); } - static void pushNamed( - BuildContext context, - String name, { - Object? extra, - Map pathParameters = const {}, - Map queryParameters = const {}, - }) { - final router = GoRouter.of(context); - final routerState = GoRouterState.of(context); - final routerStack = router.routerDelegate.currentConfiguration.matches - .map((e) => e.matchedLocation); - - final nameLocation = routerState.namedLocation( - name, - pathParameters: pathParameters, - queryParameters: queryParameters, - ); - - if (routerState.matchedLocation == nameLocation || - routerStack.contains(nameLocation)) { - return; - } - router.pushNamed( - name, - pathParameters: pathParameters, - queryParameters: queryParameters, - extra: extra, - ); - } - static DateTime parseSpotifyAlbumDate(AlbumSimple? album) { if (album == null || album.releaseDate == null) { return DateTime.parse("1975-01-01"); @@ -386,67 +318,4 @@ abstract class ServiceUtils { } }); } - - static Future checkForUpdates( - BuildContext context, - WidgetRef ref, - ) async { - if (!Env.enableUpdateChecker) return; - if (!ref.read(userPreferencesProvider.select((s) => s.checkUpdate))) return; - final packageInfo = await PackageInfo.fromPlatform(); - - if (Env.releaseChannel == ReleaseChannel.nightly) { - final value = await globalDio.getUri( - Uri.parse( - "https://api.github.com/repos/KRTirtho/spotube/actions/workflows/spotube-release-binary.yml/runs?status=success&per_page=1", - ), - options: Options( - responseType: ResponseType.json, - ), - ); - - final buildNum = value.data["workflow_runs"][0]["run_number"] as int; - - if (buildNum <= int.parse(packageInfo.buildNumber) || !context.mounted) { - return; - } - - await showDialog( - context: context, - barrierDismissible: true, - barrierColor: Colors.black26, - builder: (context) { - return RootAppUpdateDialog.nightly(nightlyBuildNum: buildNum); - }, - ); - } else { - final value = await globalDio.getUri( - Uri.parse( - "https://api.github.com/repos/KRTirtho/spotube/releases/latest", - ), - ); - final tagName = (value.data["tag_name"] as String).replaceAll("v", ""); - final currentVersion = packageInfo.version == "Unknown" - ? null - : Version.parse(packageInfo.version); - final latestVersion = - tagName == "nightly" ? null : Version.parse(tagName); - - if (currentVersion == null || - latestVersion == null || - (latestVersion.isPreRelease && !currentVersion.isPreRelease) || - (!latestVersion.isPreRelease && currentVersion.isPreRelease)) return; - - if (latestVersion <= currentVersion || !context.mounted) return; - - showDialog( - context: context, - barrierDismissible: true, - barrierColor: Colors.black26, - builder: (context) { - return RootAppUpdateDialog(version: latestVersion); - }, - ); - } - } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 2f61edd6..c69c17c0 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -15,7 +15,6 @@ #include #include #include -#include #include #include #include @@ -48,9 +47,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) system_tray_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemTrayPlugin"); system_tray_plugin_register_with_registrar(system_tray_registrar); - g_autoptr(FlPluginRegistrar) tray_manager_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); - tray_manager_plugin_register_with_registrar(tray_manager_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 48c7e0ca..a4487f4d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -12,7 +12,6 @@ list(APPEND FLUTTER_PLUGIN_LIST screen_retriever system_theme system_tray - tray_manager url_launcher_linux window_manager window_size diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 0057db14..a9f6650f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -22,7 +22,6 @@ import shared_preferences_foundation import sqflite import system_theme import system_tray -import tray_manager import url_launcher_macos import window_manager import window_size @@ -38,14 +37,13 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) - TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) WindowSizePlugin.register(with: registry.registrar(forPlugin: "WindowSizePlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 166bfa71..317de385 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -18,6 +18,9 @@ PODS: - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) + - FMDB (2.7.5): + - FMDB/standard (= 2.7.5) + - FMDB/standard (2.7.5) - local_notifier (0.1.0): - FlutterMacOS - media_kit_libs_macos_audio (1.0.4): @@ -36,15 +39,13 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS - - sqflite (0.0.3): - - Flutter + - sqflite (0.0.2): - FlutterMacOS + - FMDB (>= 2.7.5) - system_theme (0.0.1): - FlutterMacOS - system_tray (0.0.1): - FlutterMacOS - - tray_manager (0.0.1): - - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS - window_manager (0.2.0): @@ -70,16 +71,16 @@ DEPENDENCIES: - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/macos`) - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) - system_tray (from `Flutter/ephemeral/.symlinks/plugins/system_tray/macos`) - - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) SPEC REPOS: trunk: + - FMDB - OrderedSet EXTERNAL SOURCES: @@ -118,13 +119,11 @@ EXTERNAL SOURCES: shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/macos system_theme: :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos system_tray: :path: Flutter/ephemeral/.symlinks/plugins/system_tray/macos - tray_manager: - :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos window_manager: @@ -133,28 +132,28 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: - app_links: 10e0a0ab602ffaf34d142cd4862f29d34b303b2a + app_links: 4481ed4d71f384b0c3ae5016f4633aa73d32ff67 audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 - device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 + device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c - package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c - path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c + package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce + path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 - shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 - sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc system_tray: e53c972838c69589ff2e77d6d3abfd71332f9e5d - tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 diff --git a/pubspec.lock b/pubspec.lock index cf72db1c..8d19f604 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a url: "https://pub.dev" source: hosted - version: "67.0.0" + version: "61.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "5.13.0" analyzer_plugin: dependency: transitive description: name: analyzer_plugin - sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161" + sha256: c1d5f167683de03d5ab6c3b53fc9aeefc5d59476e7810ba7bbddff50c6f4392d url: "https://pub.dev" source: hosted - version: "0.11.3" + version: "0.11.2" ansicolor: dependency: transitive description: @@ -37,26 +37,90 @@ packages: dependency: "direct main" description: name: app_links - sha256: "42dc15aecf2618ace4ffb74a2e58a50e45cd1b9f2c17c8f0cafe4c297f08c815" + sha256: "4e392b5eba997df356ca6021f28431ce1cfeb16758699553a94b13add874a3bb" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "3.5.0" + app_package_maker: + dependency: transitive + description: + name: app_package_maker + sha256: "0dc1949e09a60ec2d0b79b43c5237734e6d03f7a022919bdfe1b4789f4c3bfb0" + url: "https://pub.dev" + source: hosted + version: "0.0.9" + app_package_maker_aab: + dependency: transitive + description: + name: app_package_maker_aab + sha256: "44810e77dff3b3b54011270b01a42876e838766d6e85c98f9a33bfe61db51651" + url: "https://pub.dev" + source: hosted + version: "0.0.9" + app_package_maker_apk: + dependency: transitive + description: + name: app_package_maker_apk + sha256: "974e639cda26c2e18fffaba18f88797523731f5b5457056b25e92243c9191f61" + url: "https://pub.dev" + source: hosted + version: "0.0.9" + app_package_maker_deb: + dependency: transitive + description: + name: app_package_maker_deb + sha256: dcd4047cb67648e53afd61079a8baa3c8ea383668f068e3ce8da841f3728eb29 + url: "https://pub.dev" + source: hosted + version: "0.0.9" + app_package_maker_dmg: + dependency: transitive + description: + name: app_package_maker_dmg + sha256: e0410a51304f3fff3e3850696c8e56f53f71c990e097f1c325126ebe90d242c4 + url: "https://pub.dev" + source: hosted + version: "0.0.9" + app_package_maker_exe: + dependency: transitive + description: + name: app_package_maker_exe + sha256: "07e3899a3ae12e8b6cd80efc7281ccca6c9050d2810e0fdc0e7e614cf4bd8a02" + url: "https://pub.dev" + source: hosted + version: "0.0.9" + app_package_maker_ipa: + dependency: transitive + description: + name: app_package_maker_ipa + sha256: "1a11498506ba975d02a4715650701981a382a2161c81481911517b50b378cd65" + url: "https://pub.dev" + source: hosted + version: "0.0.9" + app_package_maker_zip: + dependency: transitive + description: + name: app_package_maker_zip + sha256: cef07a47c589036a4762fdc9e61b9022f0a2a2a9f69538109a0a952a7e668306 + url: "https://pub.dev" + source: hosted + version: "0.0.9" archive: dependency: transitive description: name: archive - sha256: ecf4273855368121b1caed0d10d4513c7241dfc813f7d3c8933b36622ae9b265 + sha256: ca12e6c9ac022f33fd89128e7007fb5e97ab6e814d4fa05dd8d4f2db1e3c69cb url: "https://pub.dev" source: hosted - version: "3.5.1" + version: "3.4.5" args: dependency: "direct main" description: name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.4.2" async: dependency: "direct main" description: @@ -69,18 +133,18 @@ packages: dependency: "direct main" description: name: audio_service - sha256: "4547c312a94f9cb2c48b60823fb190767cbd63454a83c73049384d5d3cba4650" + sha256: a4d989f1225ea9621898d60f23236dcbfc04876fa316086c23c5c4af075dbac4 url: "https://pub.dev" source: hosted - version: "0.18.13" + version: "0.18.12" audio_service_mpris: dependency: "direct main" description: name: audio_service_mpris - sha256: a8d1583f9143d17b2facc994a99bd1ea257cec43adcb8d7349458555c62b570f + sha256: "31be5de2db0c71b217157afce1974ac6d0ad329bd91deb1f19ad094d29340d8e" url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.1.0" audio_service_platform_interface: dependency: transitive description: @@ -93,18 +157,18 @@ packages: dependency: transitive description: name: audio_service_web - sha256: "9d7d5ae5f98a5727f2580fad73062f2484f400eef6cef42919413268e62a363e" + sha256: "523e64ddc914c714d53eec2da85bba1074f08cf26c786d4efb322de510815ea7" url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.1" audio_session: dependency: "direct main" description: name: audio_session - sha256: a49af9981eec5d7cd73b37bacb6ee73f8143a6a9f9bd5b6021e6c346b9b6cf4e + sha256: "6fdf255ed3af86535c96452c33ecff1245990bb25a605bfb1958661ccc3d467f" url: "https://pub.dev" source: hosted - version: "0.1.19" + version: "0.1.18" auto_size_text: dependency: "direct main" description: @@ -197,18 +261,18 @@ packages: dependency: transitive description: name: build_daemon - sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" + sha256: "5f02d73eb2ba16483e693f80bee4f088563a820e47d1027d4cdfe62b5bb43e65" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.0" build_resolvers: dependency: transitive description: name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + sha256: d912852cce27c9e80a93603db721c267716894462e7033165178b91138587972 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.3.2" build_runner: dependency: "direct dev" description: @@ -221,10 +285,10 @@ packages: dependency: transitive description: name: build_runner_core - sha256: "4ae8ffe5ac758da294ecf1802f2aff01558d8b1b00616aa7538ea9a8a5d50799" + sha256: "6d6ee4276b1c5f34f21fdf39425202712d2be82019983d52f351c94aafbc2c41" url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "7.2.10" built_collection: dependency: transitive description: @@ -237,18 +301,18 @@ packages: dependency: transitive description: name: built_value - sha256: c7913a9737ee4007efedaffc968c049fd0f3d0e49109e778edc10de9426005cb + sha256: ff627b645b28fb8bdb69e645f910c2458fd6b65f6585c3a53e0626024897dedf url: "https://pub.dev" source: hosted - version: "8.9.2" + version: "8.6.2" buttons_tabbar: dependency: "direct main" description: name: buttons_tabbar - sha256: "3f0969c26574ef15c0c9ff1dee42c3c4b0d3563d2c8607804372490fb8b76896" + sha256: "781128180f3e76cf93c093183f10395c664983dbee20bc4da2025be70085c2da" url: "https://pub.dev" source: hosted - version: "1.3.8" + version: "1.3.7+1" cached_network_image: dependency: "direct main" description: @@ -277,10 +341,10 @@ packages: dependency: "direct main" description: name: catcher_2 - sha256: "3c8f6cedc8c5eab61192830096d4f303900a5d0bddbf96a07ff9f7a8d5ff8fcd" + sha256: "73e251057b1b59442d58b5109d26401cfed850df21da44b028338d4f85f69105" url: "https://pub.dev" source: hosted - version: "1.2.4" + version: "1.0.0" change_case: dependency: transitive description: @@ -317,10 +381,10 @@ packages: dependency: transitive description: name: cli_util - sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19 + sha256: b8db3080e59b2503ca9e7922c3df2072cf13992354d5e944074ffa836fba43b7 url: "https://pub.dev" source: hosted - version: "0.4.1" + version: "0.4.0" clock: dependency: transitive description: @@ -333,10 +397,10 @@ packages: dependency: transitive description: name: code_builder - sha256: f692079e25e7869c14132d39f223f8eec9830eb76131925143b2129c4bb01b37 + sha256: "315a598c7fbe77f22de1c9da7cfd6fd21816312f16ffa124453b4fc679e540f1" url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.6.0" collection: dependency: "direct main" description: @@ -365,10 +429,10 @@ packages: dependency: transitive description: name: cross_file - sha256: "55d7b444feb71301ef6b8838dbc1ae02e63dd48c8773f3810ff53bb1e2945b32" + sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb url: "https://pub.dev" source: hosted - version: "0.3.4+1" + version: "0.3.3+5" crypto: dependency: "direct main" description: @@ -385,6 +449,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d + url: "https://pub.dev" + source: hosted + version: "1.0.6" curved_navigation_bar: dependency: "direct main" description: @@ -397,26 +469,26 @@ packages: dependency: "direct dev" description: name: custom_lint - sha256: "7c0aec12df22f9082146c354692056677f1e70bc43471644d1fdb36c6fdda799" + sha256: "22bd87a362f433ba6aae127a7bac2838645270737f3721b180916d7c5946cb5d" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.5.11" custom_lint_builder: dependency: transitive description: name: custom_lint_builder - sha256: d7dc41e709dde223806660268678be7993559e523eb3164e2a1425fd6f7615a9 + sha256: "0d48e002438950f9582e574ef806b2bea5719d8d14c0f9f754fbad729bcf3b19" url: "https://pub.dev" source: hosted - version: "0.6.4" + version: "0.5.14" custom_lint_core: dependency: transitive description: name: custom_lint_core - sha256: a85e8f78f4c52f6c63cdaf8c872eb573db0231dcdf3c3a5906d493c1f8bc20e6 + sha256: "2952837953022de610dacb464f045594854ced6506ac7f76af28d4a6490e189b" url: "https://pub.dev" source: hosted - version: "0.6.3" + version: "0.5.14" dart_des: dependency: transitive description: @@ -434,22 +506,14 @@ packages: url: "https://github.com/Tommypop2/dart_discord_rpc.git" source: git version: "0.0.3" - dart_mappable: - dependency: transitive - description: - name: dart_mappable - sha256: "47269caf2060533c29b823ff7fa9706502355ffcb61e7f2a374e3a0fb2f2c3f0" - url: "https://pub.dev" - source: hosted - version: "4.2.2" dart_style: dependency: transitive description: name: dart_style - sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" + sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" url: "https://pub.dev" source: hosted - version: "2.3.6" + version: "2.3.2" dartx: dependency: transitive description: @@ -466,14 +530,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + device_frame: + dependency: transitive + description: + name: device_frame + sha256: afe76182aec178d171953d9b4a50a43c57c7cf3c77d8b09a48bf30c8fa04dd9d + url: "https://pub.dev" + source: hosted + version: "1.1.0" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: eead12d1a1ed83d8283ab4c2f3fca23ac4082f29f25f29dff0f758f57d06ec91 + sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "9.1.2" device_info_plus_platform_interface: dependency: transitive description: @@ -482,22 +554,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.0" + device_preview: + dependency: "direct main" + description: + name: device_preview + sha256: "2f097bf31b929e15e6756dbe0ec1bcb63952ab9ed51c25dc5a2c722d2b21fdaf" + url: "https://pub.dev" + source: hosted + version: "1.1.0" dio: dependency: "direct main" description: name: dio - sha256: "11e40df547d418cc0c4900a9318b26304e665da6fa4755399a9ff9efd09034b5" + sha256: "49af28382aefc53562459104f64d16b9dfd1e8ef68c862d5af436cc8356ce5a8" url: "https://pub.dev" source: hosted - version: "5.4.3+1" + version: "5.4.1" disable_battery_optimization: dependency: "direct main" description: name: disable_battery_optimization - sha256: "6b2ba802f984af141faf1b6b5fb956d5ef01f9cd555597c35b9cc335a03185ba" + sha256: b3441975ab2a3ab0c19ed78e909a88d245ce689d43d17f9b23582b1ed41c047b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.0+1" dots_indicator: dependency: transitive description: @@ -527,26 +607,18 @@ packages: dependency: "direct main" description: name: envied - sha256: bbff9c76120e4dc5e2e36a46690cf0a26feb65e7765633f4e8d916bcd173a450 + sha256: "60d3f5606c7b35bc6ef493e650d916b34351d8af2e58b7ac45881ba59dfcf039" url: "https://pub.dev" source: hosted - version: "0.5.4+1" + version: "0.3.0+3" envied_generator: dependency: "direct dev" description: name: envied_generator - sha256: "517b70de08d13dcd40e97b4e5347e216a0b1c75c99e704f3c85c0474a392d14a" + sha256: dfdbe5dc52863e54c036a4c4042afbdf1bd528cb4c1e638ecba26228ba72e9e5 url: "https://pub.dev" source: hosted - version: "0.5.4+1" - equatable: - dependency: transitive - description: - name: equatable - sha256: c2b87cb7756efdf69892005af546c56c0b5037f54d2a88269b4f347a505e3ca2 - url: "https://pub.dev" - source: hosted - version: "2.0.5" + version: "0.3.0+3" fake_async: dependency: transitive description: @@ -559,10 +631,10 @@ packages: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.0" file: dependency: transitive description: @@ -575,34 +647,34 @@ packages: dependency: "direct main" description: name: file_picker - sha256: d1d0ac3966b36dc3e66eeefb40280c17feb87fa2099c6e22e6a1fc959327bd03 + sha256: "4e42aacde3b993c5947467ab640882c56947d9d27342a5b6f2895b23956954a6" url: "https://pub.dev" source: hosted - version: "8.0.0+1" + version: "6.1.1" file_selector: dependency: "direct main" description: name: file_selector - sha256: "5019692b593455127794d5718304ff1ae15447dea286cdda9f0db2a796a1b828" + sha256: "84eaf3e034d647859167d1f01cfe7b6352488f34c1b4932635012b202014c25b" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.1" file_selector_android: dependency: transitive description: name: file_selector_android - sha256: "1cd66575f063b689e041aec836905ba7be18d76c9f0634d0d75daec825f67095" + sha256: d41e165d6f798ca941d536e5dc93494d50e78c571c28ad60cfe0b0fefeb9f1e7 url: "https://pub.dev" source: hosted - version: "0.5.0+7" + version: "0.5.0+3" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: "0a1196a9c5795858aa315332da2fb5c4bcfdcb312d8a4e27651f765b87904431" + sha256: b3fbdda64aa2e335df6e111f6b0f1bb968402ed81d2dd1fa4274267999aa32c2 url: "https://pub.dev" source: hosted - version: "0.5.1+9" + version: "0.5.1+6" file_selector_linux: dependency: transitive description: @@ -615,26 +687,26 @@ packages: dependency: transitive description: name: file_selector_macos - sha256: b15c3da8bd4908b9918111fa486903f5808e388b8d1c559949f584725a6594d6 + sha256: "182c3f8350cee659f7b115e956047ee3dc672a96665883a545e81581b9a82c72" url: "https://pub.dev" source: hosted - version: "0.9.3+3" + version: "0.9.3+2" file_selector_platform_interface: dependency: transitive description: name: file_selector_platform_interface - sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262" url: "https://pub.dev" source: hosted - version: "2.6.2" + version: "2.6.1" file_selector_web: dependency: transitive description: name: file_selector_web - sha256: "619e431b224711a3869e30dbd7d516f5f5a4f04b265013a50912f39e1abc88c8" + sha256: dc6622c4d66cb1bee623ddcc029036603c6cc45c85e4a775bb06008d61c809c1 url: "https://pub.dev" source: hosted - version: "0.9.4+1" + version: "0.9.2+1" file_selector_windows: dependency: transitive description: @@ -655,15 +727,31 @@ packages: dependency: "direct main" description: name: fluentui_system_icons - sha256: "1c860f10a0e74c5788ff8a650ae6074d9a544463ae269714f1044b32df52b978" + sha256: "7637cab80bd8d1ba762144cd85df79a7318c12ed5a66d166a9e4acbf24a4c412" url: "https://pub.dev" source: hosted - version: "1.1.234" + version: "1.1.214" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_app_builder: + dependency: transitive + description: + name: flutter_app_builder + sha256: "9e5527919f62424f0fafaa3e8dfda8469caf63e465862e9866a0d60a37c00fcf" + url: "https://pub.dev" + source: hosted + version: "0.0.9" + flutter_app_packager: + dependency: transitive + description: + name: flutter_app_packager + sha256: b5bfb7113b49710c004c5f1ab6f08ac121418540d49e14825dd75e99810fa695 + url: "https://pub.dev" + source: hosted + version: "0.0.9" flutter_broadcasts: dependency: "direct main" description: @@ -680,6 +768,15 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" + flutter_desktop_tools: + dependency: "direct main" + description: + path: "." + ref: "1f0bec3283626dcbd8ee2f54e238d096d8dea50e" + resolved-ref: "1f0bec3283626dcbd8ee2f54e238d096d8dea50e" + url: "https://github.com/KRTirtho/flutter_desktop_tools.git" + source: git + version: "0.0.1" flutter_displaymode: dependency: "direct main" description: @@ -688,6 +785,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + flutter_distributor: + dependency: "direct dev" + description: + name: flutter_distributor + sha256: "50d56df265e97396427ec42cc02374b72d08c71b3442d662b97fc089bd1705ea" + url: "https://pub.dev" + source: hosted + version: "0.0.2" flutter_driver: dependency: transitive description: flutter @@ -785,10 +890,10 @@ packages: dependency: transitive description: name: flutter_keyboard_visibility - sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8" + sha256: "4983655c26ab5b959252ee204c2fffa4afeb4413cd030455194ec0caa3b8e7cb" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "5.4.1" flutter_keyboard_visibility_linux: dependency: transitive description: @@ -841,10 +946,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "9e8c3858111da373efc5aa341de011d9bd23e2c5c5e0c62bccf32438e192d7b1" + sha256: e2a421b7e59244faef694ba7b30562e489c2b489866e505074eb005cd7060db7 url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.1" flutter_localizations: dependency: "direct main" description: flutter @@ -854,42 +959,42 @@ packages: dependency: transitive description: name: flutter_mailer - sha256: "4fffaa35e911ff5ec2e5a4ebbca62c372e99a154eb3bb2c0bf79f09adf6ecf4c" + sha256: a935e9caa842877e8ed56109afb75b86e6488edbcd4696a5ac02b327a48fcd8a url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.1" flutter_native_splash: dependency: "direct main" description: name: flutter_native_splash - sha256: edf39bcf4d74aca1eb2c1e43c3e445fd9f494013df7f0da752fefe72020eedc0 + sha256: "558f10070f03ee71f850a78f7136ab239a67636a294a44a06b6b7345178edb1e" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.3.10" flutter_plugin_android_lifecycle: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f" + sha256: f185ac890306b5779ecbd611f52502d8d4d63d27703ef73161ca0407e815f02c url: "https://pub.dev" source: hosted - version: "2.0.19" + version: "2.0.16" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod - sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d" + sha256: "4bce556b7ecbfea26109638d5237684538d4abc509d253e6c5c4c5733b360098" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.4.10" flutter_rust_bridge: dependency: transitive description: name: flutter_rust_bridge - sha256: "02720226035257ad0b571c1256f43df3e1556a499f6bcb004849a0faaa0e87f0" + sha256: e12415c3bce49bcbc3fed383f0ea41ad7d828f6cf0eccba0588ffa5a812fe522 url: "https://pub.dev" source: hosted - version: "1.82.6" + version: "1.82.1" flutter_secure_storage: dependency: "direct main" description: @@ -942,10 +1047,10 @@ packages: dependency: "direct main" description: name: flutter_sharing_intent - sha256: "785ffc391822641457f930eb477c91c2f598a888f50b8fbb40d481ee01c7e719" + sha256: "6eb896e6523b735e8230eeb206fd3b9f220f11ce879c2400a90b443147036ff9" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.0" flutter_svg: dependency: "direct main" description: @@ -968,10 +1073,10 @@ packages: dependency: transitive description: name: fluttertoast - sha256: "81b68579e23fcbcada2db3d50302813d2371664afe6165bc78148050ab94bf66" + sha256: "474f7d506230897a3cd28c965ec21c5328ae5605fc9c400cd330e9e9d6ac175c" url: "https://pub.dev" source: hosted - version: "8.2.5" + version: "8.2.2" form_validator: dependency: "direct main" description: @@ -984,10 +1089,10 @@ packages: dependency: "direct dev" description: name: freezed - sha256: a434911f643466d78462625df76fd9eb13e57348ff43fe1f77bbe909522c67a1 + sha256: "6c5031daae12c7072b3a87eff98983076434b4889ef2a44384d0cae3f82372ba" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.4.6" freezed_annotation: dependency: "direct main" description: @@ -1000,10 +1105,10 @@ packages: dependency: transitive description: name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "3.2.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -1045,10 +1150,10 @@ packages: dependency: "direct main" description: name: google_fonts - sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 + sha256: f0b8d115a13ecf827013ec9fc883390ccc0e87a96ed5347a3114cac177ef18e8 url: "https://pub.dev" source: hosted - version: "6.2.1" + version: "6.1.0" graphs: dependency: transitive description: @@ -1101,10 +1206,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: "45b2030a18bcd6dbd680c2c91bc3b33e3fe7c323e3acb5ecec93a613e2fbaa8a" + sha256: "758b07eba336e3cbacbd81dba481f2228a14102083fdde07045e8514e8054c49" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.4.10" hotreloader: dependency: transitive description: @@ -1165,42 +1270,42 @@ packages: dependency: transitive description: name: image - sha256: "4c68bfd5ae83e700b5204c1e74451e7bf3cf750e6843c6e158289cf56bda018e" + sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271" url: "https://pub.dev" source: hosted - version: "4.1.7" + version: "4.1.3" image_picker: dependency: "direct main" description: name: image_picker - sha256: fe9ee64ccb8d599a5dfb0e21cc6652232c610bcf667af4e79b9eb175cc30a7a5 + sha256: "7d7f2768df2a8b0a3cefa5ef4f84636121987d403130e70b17ef7e2cf650ba84" url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.0.4" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "8e75431a62b7feb4fd55cb4a5c6f0ac4564460ec5dc09f9c4a0d50a5ce7c4cb9" + sha256: "47da2161c2e9f8f8a9cbbd89d466d174333fbdd769aeed848912e0b16d9cb369" url: "https://pub.dev" source: hosted - version: "0.8.10" + version: "0.8.8" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" + sha256: "50bc9ae6a77eea3a8b11af5eb6c661eeb858fdd2f734c2a4fd17086922347ef7" url: "https://pub.dev" source: hosted - version: "3.0.4" + version: "3.0.1" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: f4a6f62be96d6fd268f32a6bf8ef444cd8e3fff64d16923c6e6fe55e0c84a761 + sha256: c5538cacefacac733c724be7484377923b476216ad1ead35a0d2eadcdc0fc497 url: "https://pub.dev" source: hosted - version: "0.8.10" + version: "0.8.8+2" image_picker_linux: dependency: transitive description: @@ -1221,10 +1326,10 @@ packages: dependency: transitive description: name: image_picker_platform_interface - sha256: "9ec26d410ff46f483c5519c29c02ef0e02e13a543f882b152d4bfd2f06802f80" + sha256: ed9b00e63977c93b0d2d2b343685bed9c324534ba5abafbb3dfbd6a780b1b514 url: "https://pub.dev" source: hosted - version: "2.10.0" + version: "2.9.1" image_picker_windows: dependency: transitive description: @@ -1242,20 +1347,20 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.18.1" introduction_screen: dependency: "direct main" description: name: introduction_screen - sha256: "325f26e86fa3c3e86e6ab2bbc1fda860c9e6eae5ff29166fc2a3cab8f710d5b5" + sha256: ef5a5479a8e06a84b9a7eff16c698b9b82f70cd1b6203b264bc3686f9bfb77e2 url: "https://pub.dev" source: hosted - version: "3.1.14" + version: "3.1.11" io: - dependency: "direct dev" + dependency: transitive description: name: io sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" @@ -1298,26 +1403,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.0" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "2.0.1" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "2.0.1" lints: dependency: transitive description: @@ -1327,21 +1432,21 @@ packages: source: hosted version: "3.0.0" local_notifier: - dependency: "direct main" + dependency: transitive description: name: local_notifier - sha256: f6cfc933c6fbc961f4e52b5c880f68e41b2d3cd29aad557cc654fd211093a025 + sha256: cc855aa6362c8840e3d3b35b1c3b058a3a8becdb2b03d5a9aa3f3a1e861f0a03 url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "0.1.5" logger: dependency: "direct main" description: name: logger - sha256: "8c94b8c219e7e50194efc8771cd0e9f10807d8d3e219af473d89b06cc2ee4e04" + sha256: ba3bc83117b2b49bdd723c0ea7848e8285a0fbc597ba09203b20d329d020c24a url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.0.2" logging: dependency: transitive description: @@ -1362,10 +1467,10 @@ packages: dependency: transitive description: name: mailer - sha256: d25d89555c1031abacb448f07b801d7c01b4c21d4558e944b12b64394c84a3cb + sha256: "57f6dd1496699999a7bfd0aa6be0645384f477f4823e16d4321c40a434346382" url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.0.1" matcher: dependency: transitive description: @@ -1386,26 +1491,26 @@ packages: dependency: "direct main" description: name: media_kit - sha256: "3289062540e3b8b9746e5c50d95bd78a9289826b7227e253dff806d002b9e67a" + sha256: "92c7f59e075d74471b31e703f81ccc1d7102739ebcce945b30a6417fa2f751d5" url: "https://pub.dev" source: hosted - version: "1.1.10+1" + version: "1.1.7" media_kit_libs_android_audio: dependency: transitive description: name: media_kit_libs_android_audio - sha256: "3d2df5c09d3f3ff7c55b53bf955e46712f76483e77562a5a017439a3ea85ce88" + sha256: "1d9ba8814e58a12ae69014884abf96893d38e444abfb806ff38896dfe089dd15" url: "https://pub.dev" source: hosted - version: "1.3.6" + version: "1.3.5" media_kit_libs_audio: dependency: "direct main" description: name: media_kit_libs_audio - sha256: f3f91df69848005363b3ae0ef7971a90edbd80a9365195684ef26c9a6ac8833f + sha256: "9ccf9019edc220b8d0bfa1f53a91aa2cf2506ac860b4d5774c9d6bbdd49796ca" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.3" media_kit_libs_ios_audio: dependency: transitive description: @@ -1446,22 +1551,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" - menu_base: - dependency: transitive - description: - name: menu_base - sha256: "820368014a171bd1241030278e6c2617354f492f5c703d7b7d4570a6b8b84405" - url: "https://pub.dev" - source: hosted - version: "0.1.1" meta: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.0" metadata_god: dependency: "direct main" description: @@ -1474,10 +1571,18 @@ packages: dependency: "direct main" description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: e4ff8e8564c03f255408decd16e7899da1733852a9110a58fe6d1b817684a63e url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.4" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" oauth2: dependency: transitive description: @@ -1506,10 +1611,10 @@ packages: dependency: "direct main" description: name: package_info_plus - sha256: cb44f49b6e690fa766f023d5b22cac6b9affe741dd792b6ac7ad4fabe0d7b097 + sha256: "6ff267fcd9d48cb61c8df74a82680e8b82e940231bb5f68356672fde0397334a" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "4.1.0" package_info_plus_platform_interface: dependency: transitive description: @@ -1554,26 +1659,26 @@ packages: dependency: "direct main" description: name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.1" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d + sha256: "6b8b19bd80da4f11ce91b2d1fb931f3006911477cec227cce23d3253d80df3f1" url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.2.0" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + sha256: "19314d595120f82aca0ba62787d58dde2cc6b5df7d2f0daf72489e38d1b57f2d" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.1" path_provider_linux: dependency: transitive description: @@ -1586,10 +1691,10 @@ packages: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.1" path_provider_windows: dependency: transitive description: @@ -1602,50 +1707,42 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: "18bf33f7fefbd812f37e72091a15575e72d5318854877e0e4035a24ac1113ecb" + sha256: "284a66179cabdf942f838543e10413246f06424d960c92ba95c84439154fcac8" url: "https://pub.dev" source: hosted - version: "11.3.1" + version: "11.0.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "1acac6bae58144b442f11e66621c062aead9c99841093c38f5bcdcc24c1c3474" + sha256: ace7d15a3d1a4a0b91c041d01e5405df221edb9de9116525efc773c74e6fc790 url: "https://pub.dev" source: hosted - version: "12.0.5" + version: "11.0.5" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: e9ad66020b89ff1b63908f247c2c6f931c6e62699b756ef8b3c4569350cd8662 + sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" url: "https://pub.dev" source: hosted - version: "9.4.4" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" - url: "https://pub.dev" - source: hosted - version: "0.1.1" + version: "9.1.4" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: "48d4fcf201a1dad93ee869ab0d4101d084f49136ec82a8a06ed9cfeacab9fd20" + sha256: f2343e9fa9c22ae4fd92d4732755bfe452214e7189afcc097380950cf567b4b2 url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "3.11.5" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.1.3" petitparser: dependency: transitive description: @@ -1657,10 +1754,11 @@ packages: piped_client: dependency: "direct main" description: - name: piped_client - sha256: "87b04b2ebf4e008cfbb0ac85e9920ab3741f5aa697be2dd44919658a3297a4bc" - url: "https://pub.dev" - source: hosted + path: "." + ref: HEAD + resolved-ref: "64631732eefe3d93889756dc2e4ff5c8523ed763" + url: "https://github.com/KRTirtho/piped_client.git" + source: git version: "0.1.1" platform: dependency: transitive @@ -1674,10 +1772,18 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + sha256: da3fdfeccc4d4ff2da8f8c556704c08f912542c5fb3cf2233ed75372384a034d url: "https://pub.dev" source: hosted - version: "2.1.8" + version: "2.1.6" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "7c1e5f0d23c9016c5bbd8b1473d0d3fb3fc851b876046039509e18e0c7485f2c" + url: "https://pub.dev" + source: hosted + version: "3.7.3" pool: dependency: transitive description: @@ -1702,22 +1808,22 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" - process_run: - dependency: "direct dev" + provider: + dependency: transitive description: - name: process_run - sha256: "8d9c6198b98fbbfb511edd42e7364e24d85c163e47398919871b952dc86a423e" + name: provider + sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f url: "https://pub.dev" source: hosted - version: "0.14.2" + version: "6.0.5" pub_api_client: dependency: "direct main" description: name: pub_api_client - sha256: cc3d2c93df3823553de6a3e7d3ac09a3f43f8c271af4f43c2795266090ac9625 + sha256: d456816ef5142906a22dc56e37be6bef6cb0276f0a26c11d1f7d277868202e71 url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.6.0" pub_semver: dependency: transitive description: @@ -1746,10 +1852,10 @@ packages: dependency: transitive description: name: puppeteer - sha256: "6833edca01b1e9dcdd9a6e41bad84b706dfba4366d095c4edff64b00c02ac472" + sha256: eedeaae6ec5d2e54f9ae22ab4d6b3dda2e8791c356cc783046d06c287ffe11d8 url: "https://pub.dev" source: hosted - version: "3.8.0" + version: "3.6.0" quiver: dependency: transitive description: @@ -1758,38 +1864,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" - recase: - dependency: transitive - description: - name: recase - sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 - url: "https://pub.dev" - source: hosted - version: "4.1.0" riverpod: dependency: transitive description: name: riverpod - sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d + sha256: "548e2192eb7aeb826eb89387f814edb76594f3363e2c0bb99dd733d795ba3589" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.5.0" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: "8b71f03fc47ae27d13769496a1746332df4cec43918aeba9aff1e232783a780f" + sha256: d72d7096964baf288b55619fe48100001fc4564ab7923ed0a7f5c7650e03c0d6 url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.3.4" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "3c67c14ccd16f0c9d53e35ef70d06cd9d072e2fb14557326886bbde903b230a5" + sha256: "70198738c3047ae4f6517ef1a2011a8514a980a52576c7f629a3a08810319a02" url: "https://pub.dev" source: hosted - version: "2.3.10" + version: "2.1.1" rxdart: dependency: transitive description: @@ -1835,34 +1933,34 @@ packages: dependency: transitive description: name: sentry - sha256: "19a267774906ca3a3c4677fc7e9582ea9da79ae9a28f84bbe4885dac2c269b70" + sha256: "39c23342fc96105da449914f7774139a17a0ca8a4e70d9ad5200171f7e47d6ba" url: "https://pub.dev" source: hosted - version: "7.20.0" + version: "7.9.0" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: d3bbe5553a986e83980916ded2f0b435ef2e1893dfaa29d5a7a790d0eca12180 + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.2" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" + sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.1" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" + sha256: "7bf53a9f2d007329ee6f3df7268fd498f8373602f943c975598bbb34649b62a7" url: "https://pub.dev" source: hosted - version: "2.3.5" + version: "2.3.4" shared_preferences_linux: dependency: transitive description: @@ -1875,18 +1973,18 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.1" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + sha256: d762709c2bbe80626ecc819143013cc820fa49ca5e363620ee20a8b15a3e3daf url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.2.1" shared_preferences_windows: dependency: transitive description: @@ -1927,30 +2025,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - shortid: - dependency: transitive - description: - name: shortid - sha256: d0b40e3dbb50497dad107e19c54ca7de0d1a274eb9b4404991e443dadb9ebedb - url: "https://pub.dev" - source: hosted - version: "0.1.2" sidebarx: dependency: "direct main" description: name: sidebarx - sha256: abe39d6db237fb8e25c600e8039ffab80fa7fe71acab03e9c378c31f912d2766 + sha256: "7042d64844b8e64ca5c17e70d89b49df35b54a26c015b90000da9741eab70bc0" url: "https://pub.dev" source: hosted - version: "0.17.1" + version: "0.16.3" simple_icons: dependency: "direct main" description: name: simple_icons - sha256: "30067d70a9d72923fbc80e142e17fa46085dfa970e66bc4bede3be4819d05901" + sha256: "8aa6832dc7a263a3213e40ecbf1328a392308c809d534a3b860693625890483b" url: "https://pub.dev" source: hosted - version: "10.1.3" + version: "7.10.0" skeleton_text: dependency: "direct main" description: @@ -1963,10 +2053,10 @@ packages: dependency: "direct main" description: name: skeletonizer - sha256: "9a3ae2f4ee4349bdbed3292d04586a1315a44745d2c454684f82f0c46dbeabf9" + sha256: ff4c36e826efd5288d7a84e7619a6e9be8185d3064cecf101a9133762f3b401b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "0.8.0" sky_engine: dependency: transitive description: flutter @@ -1984,18 +2074,18 @@ packages: dependency: "direct main" description: name: smtc_windows - sha256: "799bbe0f8e4436da852c5dcc0be482c97b8ae0f504f65c6b750cd239b4835aa0" + sha256: aba2bad5ddfaf595496db04df3d9fdb54fb128fc1f39c8f024945a67455388fe url: "https://pub.dev" source: hosted - version: "0.1.2" + version: "0.1.1" source_gen: dependency: transitive description: name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.4.0" source_helper: dependency: transitive description: @@ -2015,36 +2105,27 @@ packages: spotify: dependency: "direct main" description: - path: "." - ref: "fix/explicit-to-json" - resolved-ref: c4b37c599413ac7bfd78993e416a56105c62b634 - url: "https://github.com/KRTirtho/spotify-dart.git" - source: git - version: "0.13.6" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" + name: spotify + sha256: "2308a84511c18ec1e72515a57e28abb1467389549d571c460732b4538c2e34de" url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "0.13.3" sqflite: dependency: transitive description: name: sqflite - sha256: "5ce2e1a15e822c3b4bfb5400455775e421da7098eed8adc8f26298ada7c9308c" + sha256: "591f1602816e9c31377d5f008c2d9ef7b8aca8941c3f89cc5fd9d84da0c38a9a" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.0" sqflite_common: dependency: transitive description: name: sqflite_common - sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4" + sha256: "1b92f368f44b0dee2425bb861cfa17b6f6cf3961f762ff6f941d20b33355660a" url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.0" stack_trace: dependency: transitive description: @@ -2057,10 +2138,10 @@ packages: dependency: transitive description: name: state_notifier - sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + sha256: "8fe42610f179b843b12371e40db58c9444f8757f8b69d181c97e50787caed289" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "0.7.2+1" stream_channel: dependency: transitive description: @@ -2105,10 +2186,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60" url: "https://pub.dev" source: hosted - version: "3.1.0+1" + version: "3.1.0" system_theme: dependency: "direct main" description: @@ -2128,11 +2209,10 @@ packages: system_tray: dependency: "direct overridden" description: - path: "." - ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c - resolved-ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c - url: "https://github.com/antler119/system_tray" - source: git + name: system_tray + sha256: "087edb877f22f286d82d42f330fa640138c192e98aa9d20c2b83aa4e406bb432" + url: "https://pub.dev" + source: hosted version: "2.0.2" term_glyph: dependency: transitive @@ -2146,18 +2226,18 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.6.1" time: dependency: transitive description: name: time - sha256: ad8e018a6c9db36cb917a031853a1aae49467a93e0d464683e029537d848c221 + sha256: "83427e11d9072e038364a5e4da559e85869b227cf699a541be0da74f14140124" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.3" timezone: dependency: "direct main" description: @@ -2182,14 +2262,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - tray_manager: - dependency: "direct main" - description: - name: tray_manager - sha256: e0ac9a88b2700f366b8629b97e8663b6ef450a2f169560a685dc167bfe9c9c29 - url: "https://pub.dev" - source: hosted - version: "0.2.2" tuple: dependency: transitive description: @@ -2198,14 +2270,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" - type_plus: - dependency: transitive - description: - name: type_plus - sha256: d5d1019471f0d38b91603adb9b5fd4ce7ab903c879d2fbf1a3f80a630a03fcc9 - url: "https://pub.dev" - source: hosted - version: "2.1.1" typed_data: dependency: transitive description: @@ -2250,74 +2314,74 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" + sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" url: "https://pub.dev" source: hosted - version: "6.2.6" + version: "6.1.14" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "360a6ed2027f18b73c8d98e159dda67a61b7f2e0f6ec26e86c3ada33b0621775" + sha256: b04af59516ab45762b2ca6da40fa830d72d0f6045cd97744450b73493fa76330 url: "https://pub.dev" source: hosted - version: "6.3.1" + version: "6.1.0" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "9149d493b075ed740901f3ee844a38a00b33116c7c5c10d7fb27df8987fb51d5" + sha256: "7c65021d5dee51813d652357bc65b8dd4a6177082a9966bc8ba6ee477baa795f" url: "https://pub.dev" source: hosted - version: "6.2.5" + version: "6.1.5" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + sha256: b651aad005e0cb06a01dbd84b428a301916dc75f0e7ea6165f80057fee2d8e8e url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.0.6" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: b7244901ea3cf489c5335bdacda07264a6e960b1c1b1a9f91e4bc371d9e68234 + sha256: b55486791f666e62e0e8ff825e58a023fd6b1f71c49926483f1128d3bbd8fe88 url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.0.7" url_launcher_platform_interface: dependency: transitive description: name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + sha256: "95465b39f83bfe95fcb9d174829d6476216f2d548b79c38ab2506e0458787618" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.1.5" url_launcher_web: dependency: transitive description: name: url_launcher_web - sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.0.20" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + sha256: "95fef3129dc7cfaba2bc3d5ba2e16063bb561fc6d78e63eee16162bc70029069" url: "https://pub.dev" source: hosted - version: "3.1.1" + version: "3.0.8" uuid: dependency: "direct main" description: name: uuid - sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" + sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "3.0.7" vector_math: dependency: transitive description: @@ -2354,10 +2418,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "13.0.0" watcher: dependency: transitive description: @@ -2370,18 +2434,18 @@ packages: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.5.0" web_socket_channel: dependency: "direct main" description: name: web_socket_channel - sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "2.4.4" webdriver: dependency: transitive description: @@ -2402,26 +2466,26 @@ packages: dependency: transitive description: name: win32 - sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" + sha256: "9e82a402b7f3d518fb9c02d0e9ae45952df31b9bf34d77baf19da2de03fc2aaa" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "5.0.7" win32_registry: dependency: "direct main" description: name: win32_registry - sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb" + sha256: "41fd8a189940d8696b1b810efb9abcf60827b6cbfab90b0c43e8439e3a39d85a" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.2" window_manager: dependency: "direct main" description: name: window_manager - sha256: "8699323b30da4cdbe2aa2e7c9de567a6abd8a97d9a5c850a3c86dcd0b34bbfbf" + sha256: "6ee795be9124f90660ea9d05e581a466de19e1c89ee74fc4bf528f60c8600edd" url: "https://pub.dev" source: hosted - version: "0.3.9" + version: "0.3.6" window_size: dependency: "direct main" description: @@ -2435,12 +2499,12 @@ packages: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "589ada45ba9e39405c198fe34eb0f607cddb2108527e658136120892beac46d2" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.0.3" xml: - dependency: "direct dev" + dependency: transitive description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 @@ -2459,10 +2523,10 @@ packages: dependency: "direct main" description: name: youtube_explode_dart - sha256: "26c9671d638f3396a1bfb2666f586988ee7b0ba3469e478b22a4c1a168bcf6ee" + sha256: "98fd11b51adbbca76cbdb17f560168f1d7a9835cecceea965f49eb1e5eed155c" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.0.2" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.19.2" + flutter: ">=3.16.0" diff --git a/pubspec.yaml b/pubspec.yaml index c256f66e..3f4c22af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.7.0+31 +version: 3.6.0+30 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube @@ -13,87 +13,96 @@ environment: flutter: ">=3.10.0" dependencies: - args: ^2.5.0 + args: ^2.3.2 async: ^2.9.0 - audio_service: ^0.18.13 - audio_service_mpris: ^0.1.3 - audio_session: ^0.1.19 + audio_service: ^0.18.9 + audio_session: ^0.1.18 auto_size_text: ^3.0.0 - buttons_tabbar: ^1.3.8 + buttons_tabbar: ^1.3.6 cached_network_image: ^3.3.1 - catcher_2: ^1.2.4 + catcher_2: 1.0.0 collection: ^1.15.0 + cupertino_icons: ^1.0.5 curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 - device_info_plus: ^10.1.0 - dio: ^5.4.3+1 - disable_battery_optimization: ^1.1.1 + device_info_plus: ^9.1.2 + device_preview: ^1.1.0 + dio: ^5.4.1 + disable_battery_optimization: ^1.1.0+1 duration: ^3.0.12 - envied: ^0.5.4+1 - file_picker: ^8.0.0+1 - file_selector: ^1.0.3 - fluentui_system_icons: ^1.1.234 + envied: ^0.3.0 + file_selector: ^1.0.1 + fluentui_system_icons: ^1.1.189 flutter: sdk: flutter flutter_cache_manager: ^3.3.0 + flutter_desktop_tools: + git: + url: https://github.com/KRTirtho/flutter_desktop_tools.git + ref: 1f0bec3283626dcbd8ee2f54e238d096d8dea50e flutter_displaymode: ^0.6.0 flutter_feather_icons: ^2.0.0+1 flutter_hooks: ^0.20.5 flutter_inappwebview: ^6.0.0 flutter_localizations: sdk: flutter - flutter_native_splash: ^2.4.0 - flutter_riverpod: ^2.5.1 + flutter_native_splash: ^2.3.10 + flutter_riverpod: ^2.4.10 flutter_secure_storage: ^9.0.0 flutter_svg: ^1.1.6 form_validator: ^2.1.1 fuzzywuzzy: ^1.1.6 go_router: 12.1.3 # Stuck on this https://github.com/flutter/flutter/issues/140869 - google_fonts: ^6.2.1 + google_fonts: ^6.1.0 hive: ^2.2.3 hive_flutter: ^1.1.0 - hooks_riverpod: ^2.5.1 + hooks_riverpod: ^2.4.3 html: ^0.15.1 - image_picker: ^1.1.0 - intl: any - introduction_screen: ^3.1.14 + http: ^1.2.0 + image_picker: ^1.0.4 + intl: ^0.18.0 + introduction_screen: ^3.0.2 json_annotation: ^4.8.1 logger: ^2.0.2 - media_kit: ^1.1.10+1 - media_kit_libs_audio: ^1.0.4 + media_kit: ^1.1.3 + media_kit_libs_audio: ^1.0.3 metadata_god: ^0.5.2+1 mime: ^1.0.2 - package_info_plus: ^6.0.0 + package_info_plus: ^4.1.0 palette_generator: ^0.3.3 path: ^1.8.0 - path_provider: ^2.1.3 - permission_handler: ^11.3.1 - piped_client: ^0.1.1 + path_provider: ^2.0.8 + permission_handler: ^11.0.1 + piped_client: + git: + url: https://github.com/KRTirtho/piped_client.git popover: ^0.3.0 scrobblenaut: git: url: https://github.com/KRTirtho/scrobblenaut.git ref: dart-3-support scroll_to_index: ^3.0.1 - sidebarx: ^0.17.1 - shared_preferences: ^2.2.3 + sidebarx: ^0.16.3 + shared_preferences: ^2.2.2 skeleton_text: ^3.0.1 - smtc_windows: ^0.1.2 + smtc_windows: ^0.1.1 stroke_text: ^0.0.2 system_theme: ^2.1.0 titlebar_buttons: ^1.0.0 - url_launcher: ^6.2.6 - uuid: ^4.4.0 + url_launcher: ^6.1.7 + uuid: ^3.0.7 version: ^3.0.2 visibility_detector: ^0.4.0+2 - window_manager: ^0.3.9 + window_manager: ^0.3.1 window_size: git: url: https://github.com/google/flutter-desktop-embedding.git ref: a738913c8ce2c9f47515382d40827e794a334274 path: plugins/window_size - youtube_explode_dart: ^2.2.1 - simple_icons: ^10.1.3 + youtube_explode_dart: ^2.0.1 + simple_icons: ^7.10.0 + audio_service_mpris: ^0.1.0 + file_picker: ^6.0.0 jiosaavn: ^0.1.0 draggable_scrollbar: git: @@ -107,33 +116,28 @@ dependencies: url: https://github.com/Tommypop2/dart_discord_rpc.git html_unescape: ^2.0.0 wikipedia_api: ^0.1.0 - skeletonizer: ^1.1.1 - app_links: ^4.0.1 - win32_registry: ^1.1.3 + skeletonizer: ^0.8.0 + app_links: ^3.5.0 + win32_registry: ^1.1.2 flutter_sharing_intent: ^1.1.0 flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 - spotify: - git: - url: https://github.com/KRTirtho/spotify-dart.git - ref: fix/explicit-to-json + spotify: ^0.13.3 bonsoir: ^5.1.9 shelf: ^1.4.1 shelf_router: ^1.1.4 shelf_web_socket: ^1.0.4 - web_socket_channel: ^2.4.5 + web_socket_channel: ^2.4.4 lrc: ^1.0.2 pub_api_client: ^2.4.0 pubspec_parse: ^1.2.2 timezone: ^0.9.2 crypto: ^3.0.3 - local_notifier: ^0.1.6 - tray_manager: ^0.2.2 - http: ^1.2.1 dev_dependencies: build_runner: ^2.4.9 - envied_generator: ^0.5.4+1 + envied_generator: ^0.3.0+3 + flutter_distributor: ^0.0.2 flutter_gen_runner: ^5.4.0 flutter_launcher_icons: ^0.13.1 flutter_lints: ^3.0.1 @@ -143,26 +147,12 @@ dev_dependencies: sdk: flutter hive_generator: ^2.0.0 json_serializable: ^6.6.2 - freezed: ^2.5.2 - custom_lint: ^0.6.4 - riverpod_lint: ^2.3.10 - process_run: ^0.14.2 - xml: ^6.5.0 - io: ^1.0.4 + freezed: ^2.4.6 + custom_lint: ^0.5.11 + riverpod_lint: ^2.1.1 dependency_overrides: - uuid: ^4.4.0 - system_tray: - # TODO: remove this when flutter_desktop_tools gets updated - # to use [MenuItemBase] instead of [MenuItem] - git: - url: https://github.com/antler119/system_tray - ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c - # media_kit_native_event_loop: # to fix "macro name must be an identifier" - # git: - # url: https://github.com/media-kit/media-kit - # path: media_kit_native_event_loop - # ref: main + system_tray: 2.0.2 flutter: generate: true diff --git a/untranslated_messages.json b/untranslated_messages.json index aaf06929..9e26dfee 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,209 +1 @@ -{ - "ar": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "bn": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ca": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "cs": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "de": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "es": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "eu": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "fa": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "fi": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "fr": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "hi": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "id": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "it": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ja": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ka": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ko": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ne": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "nl": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "pl": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "pt": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "ru": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "th": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "tr": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "uk": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "vi": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ], - - "zh": [ - "local_library", - "add_library_location", - "remove_library_location", - "local_tab", - "stats" - ] -} +{} \ No newline at end of file diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index 0c638eb7..6e1e3cb3 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -1,16 +1,13 @@ -# Project-level configuration. cmake_minimum_required(VERSION 3.14) project(spotube LANGUAGES CXX) -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. set(BINARY_NAME "spotube") -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) +cmake_policy(SET CMP0063 NEW) -# Define build configuration option. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Configure build options. get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) if(IS_MULTICONFIG) set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" @@ -23,7 +20,7 @@ else() "Debug" "Profile" "Release") endif() endif() -# Define settings for the Profile build mode. + set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") @@ -33,10 +30,6 @@ set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") add_definitions(-DUNICODE -D_UNICODE) # Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. function(APPLY_STANDARD_SETTINGS TARGET) target_compile_features(${TARGET} PUBLIC cxx_std_17) target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") @@ -45,14 +38,14 @@ function(APPLY_STANDARD_SETTINGS TARGET) target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") endfunction() -# Flutter library and tool build rules. set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") + +# Flutter library and tool build rules. add_subdirectory(${FLUTTER_MANAGED_DIR}) -# Application build; see runner/CMakeLists.txt. +# Application build add_subdirectory("runner") - # Generated plugin build rules, which manage building the plugins and adding # them to the application. include(flutter/generated_plugins.cmake) @@ -87,12 +80,6 @@ if(PLUGIN_BUNDLED_LIBRARIES) COMPONENT Runtime) endif() -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - # Fully re-copy the assets directory on each build to avoid having stale files # from a previous install. set(FLUTTER_ASSET_DIR_NAME "flutter_assets") diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index f2dd9714..d8a9db29 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -45,8 +44,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("SystemThemePlugin")); SystemTrayPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SystemTrayPlugin")); - TrayManagerPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index f4e14280..90292744 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -14,7 +14,6 @@ list(APPEND FLUTTER_PLUGIN_LIST screen_retriever system_theme system_tray - tray_manager url_launcher_windows window_manager window_size diff --git a/windows/packaging/exe/inno_setup.iss b/windows/packaging/exe/inno_setup.iss index dbb8082b..64acc2b3 100644 --- a/windows/packaging/exe/inno_setup.iss +++ b/windows/packaging/exe/inno_setup.iss @@ -12,7 +12,7 @@ AppPublisher={{PUBLISHER_NAME}} AppPublisherURL={{PUBLISHER_URL}} AppSupportURL={{PUBLISHER_URL}} AppUpdatesURL={{PUBLISHER_URL}} -DefaultDirName={autopf}\{{DISPLAY_NAME}} +DefaultDirName={{INSTALL_DIR_NAME}} DisableProgramGroupPage=yes OutputDir=. OutputBaseFilename={{OUTPUT_BASE_FILENAME}} diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 27632667..e8fccc8a 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -60,16 +60,16 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" // Version // -#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) -#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#ifdef FLUTTER_BUILD_NUMBER +#define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER #else -#define VERSION_AS_NUMBER 1,0,0,0 +#define VERSION_AS_NUMBER 1,0,0 #endif -#if defined(FLUTTER_VERSION) -#define VERSION_AS_STRING FLUTTER_VERSION +#ifdef FLUTTER_BUILD_NAME +#define VERSION_AS_STRING #FLUTTER_BUILD_NAME #else -#define VERSION_AS_STRING "3.7.0" +#define VERSION_AS_STRING "%{{SPOTUBE_VERSION}}%" #endif VS_VERSION_INFO VERSIONINFO @@ -93,7 +93,7 @@ BEGIN VALUE "FileDescription", "Spotube" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "spotube" "\0" - VALUE "LegalCopyright", "Copyright (C) 2024 oss.krtirtho. All rights reserved." "\0" + VALUE "LegalCopyright", "Copyright (C) 2022 oss.krtirtho. All rights reserved." "\0" VALUE "OriginalFilename", "spotube.exe" "\0" VALUE "ProductName", "spotube" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" From 73bd207ce24db6e3739343fc00e1d179eb75abd7 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 18:54:18 +0600 Subject: [PATCH 117/261] fix(linux): change app id in flatpak environment --- lib/services/sourced_track/sourced_track.dart | 2 +- linux/my_application.cc | 19 ++++++++++++- pubspec.lock | 28 +++++++++---------- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index fc3f2e50..7eedfad8 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -138,7 +138,7 @@ abstract class SourcedTrack extends Track { return await JioSaavnSourcedTrack.fetchFromTrack( track: track, ref: ref, - weakMatch: true, + weakMatch: preferences.audioSource == AudioSource.jiosaavn, ); } rethrow; diff --git a/linux/my_application.cc b/linux/my_application.cc index 767025ca..8b7baffc 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -107,9 +107,26 @@ static void my_application_class_init(MyApplicationClass* klass) { static void my_application_init(MyApplication* self) {} +bool is_flatpak(void) { + if (getenv("container") || getenv("FLATPAK_ID") || getenv("FLATPAK")) { + /* flatpak */ + return true; + } + return false; /* No container detected */ +} + MyApplication* my_application_new() { + // gchar based alternate MY_APPLICATION_ID + const char* my_application_id = APPLICATION_ID; + + if (!is_flatpak()) { + my_application_id = "com.github.KRTirtho.Spotube"; + } + + g_print("Application ID: %s\n", my_application_id); + return MY_APPLICATION(g_object_new( - my_application_get_type(), "application-id", APPLICATION_ID, "flags", + my_application_get_type(), "application-id", my_application_id, "flags", G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, nullptr)); } \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index da410958..c11577f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1242,10 +1242,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.19.0" introduction_screen: dependency: "direct main" description: @@ -1298,26 +1298,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -1458,10 +1458,10 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" metadata_god: dependency: "direct main" description: @@ -2146,10 +2146,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" time: dependency: transitive description: @@ -2354,10 +2354,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" watcher: dependency: transitive description: From eea8fd1579ce496d9b8d0f7a2d331a1aa35ca43b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 19:23:14 +0600 Subject: [PATCH 118/261] chore: syntax error --- linux/my_application.cc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/linux/my_application.cc b/linux/my_application.cc index 8b7baffc..240e1b9e 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -119,7 +119,7 @@ MyApplication* my_application_new() { // gchar based alternate MY_APPLICATION_ID const char* my_application_id = APPLICATION_ID; - if (!is_flatpak()) { + if (is_flatpak()) { my_application_id = "com.github.KRTirtho.Spotube"; } From 9c3f9733b5182d0b668be1e9ba0a7e02efaa31c5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 19:51:03 +0600 Subject: [PATCH 119/261] chore: remove print statement --- linux/my_application.cc | 2 -- 1 file changed, 2 deletions(-) diff --git a/linux/my_application.cc b/linux/my_application.cc index 240e1b9e..0aa4d905 100644 --- a/linux/my_application.cc +++ b/linux/my_application.cc @@ -123,8 +123,6 @@ MyApplication* my_application_new() { my_application_id = "com.github.KRTirtho.Spotube"; } - g_print("Application ID: %s\n", my_application_id); - return MY_APPLICATION(g_object_new( my_application_get_type(), "application-id", my_application_id, "flags", G_APPLICATION_HANDLES_COMMAND_LINE | G_APPLICATION_HANDLES_OPEN, From 63bf694d5cce7af44d9ec2ce2b5b111bc47a57e2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 6 Jun 2024 20:28:42 +0600 Subject: [PATCH 120/261] chore: update aur srcinfo --- aur-struct/.SRCINFO | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/aur-struct/.SRCINFO b/aur-struct/.SRCINFO index ae0b6d10..29eedf74 100644 --- a/aur-struct/.SRCINFO +++ b/aur-struct/.SRCINFO @@ -1,17 +1,17 @@ pkgbase = spotube-bin - pkgdesc = Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! - pkgver = 2.3.0 - pkgrel = 1 - url = https://github.com/KRTirtho/spotube/ - arch = x86_64 - license = BSD-4-Clause - depends = mpv - depends = libappindicator-gtk3 - depends = libsecret - depends = jsoncpp - depends = libnotify - depends = xdg-user-dirs - source = https://github.com/KRTirtho/spotube/releases/download/v2.3.0/Spotube-linux-x86_64.tar.xz - md5sums = 8cd6a7385c5c75d203dccd762f1d63ec +pkgdesc = Open source Spotify client that doesn't require Premium nor uses Electron! Available for both desktop & mobile! +pkgver = 3.7.1 +pkgrel = 2 +url = https://github.com/KRTirtho/spotube/ +arch = x86_64 +license = BSD-4-Clause +depends = mpv +depends = libappindicator-gtk3 +depends = libsecret +depends = jsoncpp +depends = libnotify +depends = xdg-user-dirs +source = https://github.com/KRTirtho/spotube/releases/download/v3.7.1/spotube-linux-3.7.1-x86_64.tar.xz +md5sums = 475b1ae9b08f27743a4d4749391ae3db pkgname = spotube-bin From f81219e83ef3787f4ab7867f8e7a2a7d94323540 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 7 Jun 2024 22:00:16 +0600 Subject: [PATCH 121/261] chore: introduce breakpoint enum for constrains --- lib/components/library/user_playlists.dart | 6 ++-- lib/extensions/constrains.dart | 32 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 069dfad9..246c3947 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -3,7 +3,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:collection/collection.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'; @@ -17,9 +16,11 @@ import 'package:spotube/components/playlist/playlist_card.dart'; import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/service_utils.dart'; class UserPlaylists extends HookConsumerWidget { const UserPlaylists({super.key}); @@ -110,7 +111,8 @@ class UserPlaylists extends HookConsumerWidget { icon: const Icon(SpotubeIcons.magic), label: Text(context.l10n.generate_playlist), onPressed: () { - GoRouter.of(context).push("/library/generate"); + ServiceUtils.pushNamed( + context, PlaylistGeneratorPage.name); }, ), const Gap(10), diff --git a/lib/extensions/constrains.dart b/lib/extensions/constrains.dart index 1177f5ac..dc1027e2 100644 --- a/lib/extensions/constrains.dart +++ b/lib/extensions/constrains.dart @@ -1,6 +1,20 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart'; +enum Breakpoint { + xs, + sm, + md, + lg, + xl, + xxl; + + bool operator <=(Breakpoint other) => index <= other.index; + bool operator <(Breakpoint other) => index < other.index; + bool operator >(Breakpoint other) => index > other.index; + bool operator >=(Breakpoint other) => index >= other.index; +} + // ignore: constant_identifier_names const Breakpoints = ( xs: 480.0, @@ -22,6 +36,15 @@ extension SliverBreakpoints on SliverConstraints { crossAxisExtent > Breakpoints.lg && crossAxisExtent <= Breakpoints.xl; bool get is2Xl => crossAxisExtent > Breakpoints.xl; + Breakpoint get breakpoint { + if (isXs) return Breakpoint.xs; + if (isSm) return Breakpoint.sm; + if (isMd) return Breakpoint.md; + if (isLg) return Breakpoint.lg; + if (isXl) return Breakpoint.xl; + return Breakpoint.xxl; + } + bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl; bool get mdAndUp => isMd || isLg || isXl || is2Xl; bool get lgAndUp => isLg || isXl || is2Xl; @@ -45,6 +68,15 @@ extension ContainerBreakpoints on BoxConstraints { biggest.width > Breakpoints.lg && biggest.width <= Breakpoints.xl; bool get is2Xl => biggest.width > Breakpoints.xl; + Breakpoint get breakpoint { + if (isXs) return Breakpoint.xs; + if (isSm) return Breakpoint.sm; + if (isMd) return Breakpoint.md; + if (isLg) return Breakpoint.lg; + if (isXl) return Breakpoint.xl; + return Breakpoint.xxl; + } + bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl; bool get mdAndUp => isMd || isLg || isXl || is2Xl; bool get lgAndUp => isLg || isXl || is2Xl; From 1cfeef54e7c5d8549457cdc6aefab51b4fc7445f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 7 Jun 2024 22:19:45 +0600 Subject: [PATCH 122/261] refactor: move route related components to modules folder --- lib/collections/intents.dart | 2 +- .../dialogs/playlist_add_track_dialog.dart | 2 +- .../horizontal_playbutton_card_view.dart | 6 ++-- .../shared/sort_tracks_dropdown.dart | 2 +- .../sections/header/header_actions.dart | 2 +- .../tracks_view/track_view_provider.dart | 2 +- lib/components/stats/common/album_item.dart | 2 +- .../album/album_card.dart | 0 .../artist/artist_album_list.dart | 0 .../artist/artist_card.dart | 0 .../connect/connect_device.dart | 0 .../connect/local_devices.dart | 0 .../desktop_login/login_form.dart | 0 .../getting_started/blur_card.dart | 0 .../home/sections/featured.dart | 0 .../home/sections/feed.dart | 0 .../home/sections/friends.dart | 2 +- .../home/sections/friends/friend_item.dart | 0 .../home/sections/genres.dart | 0 .../home/sections/made_for_user.dart | 0 .../home/sections/new_releases.dart | 0 .../home/sections/recent.dart | 0 .../local_folder/local_folder_item.dart | 0 .../playlist_generate/multi_select_field.dart | 0 .../recommendation_attribute_dials.dart | 0 .../recommendation_attribute_fields.dart | 2 +- .../seeds_multi_autocomplete.dart | 0 .../playlist_generate/simple_track_tile.dart | 0 .../library/user_albums.dart | 2 +- .../library/user_artists.dart | 2 +- .../library/user_downloads.dart | 2 +- .../library/user_downloads/download_item.dart | 0 .../library/user_local_tracks.dart | 2 +- .../library/user_playlists.dart | 4 +-- .../lyrics/use_synced_lyrics.dart | 0 .../lyrics/zoom_controls.dart | 0 .../player/player.dart | 8 +++--- .../player/player_actions.dart | 2 +- .../player/player_controls.dart | 2 +- .../player/player_overlay.dart | 8 +++--- .../player/player_queue.dart | 0 .../player/player_track_details.dart | 0 .../player/sibling_tracks_sheet.dart | 0 .../player/use_progress.dart | 0 .../player/volume_slider.dart | 0 .../playlist/playlist_card.dart | 0 .../playlist/playlist_create_dialog.dart | 0 .../root/bottom_player.dart | 10 +++---- lib/{components => modules}/root/sidebar.dart | 2 +- .../root/spotube_navigation_bar.dart | 0 .../root/update_dialog.dart | 0 .../settings/color_scheme_picker_dialog.dart | 0 .../settings/section_card_with_heading.dart | 0 lib/pages/artist/artist.dart | 2 +- lib/pages/artist/section/related_artists.dart | 2 +- lib/pages/connect/connect.dart | 2 +- lib/pages/connect/control/control.dart | 4 +-- lib/pages/desktop_login/desktop_login.dart | 2 +- lib/pages/desktop_login/login_tutorial.dart | 2 +- .../getting_started/sections/greeting.dart | 2 +- .../getting_started/sections/playback.dart | 2 +- .../getting_started/sections/region.dart | 2 +- .../getting_started/sections/support.dart | 2 +- lib/pages/home/feed/feed_section.dart | 6 ++-- lib/pages/home/genres/genre_playlists.dart | 2 +- lib/pages/home/home.dart | 16 +++++------ lib/pages/library/library.dart | 10 +++---- lib/pages/library/local_folder.dart | 2 +- .../playlist_generate/playlist_generate.dart | 10 +++---- .../playlist_generate_result.dart | 4 +-- lib/pages/lyrics/mini_lyrics.dart | 6 ++-- lib/pages/lyrics/plain_lyrics.dart | 2 +- lib/pages/lyrics/synced_lyrics.dart | 4 +-- lib/pages/root/root_app.dart | 8 +++--- lib/pages/settings/logs.dart | 2 +- lib/pages/settings/sections/about.dart | 2 +- lib/pages/settings/sections/accounts.dart | 2 +- lib/pages/settings/sections/appearance.dart | 4 +-- lib/pages/settings/sections/desktop.dart | 2 +- lib/pages/settings/sections/developers.dart | 2 +- lib/pages/settings/sections/downloads.dart | 3 +- .../settings/sections/language_region.dart | 2 +- lib/pages/settings/sections/playback.dart | 2 +- .../user_preferences_provider.dart | 2 +- .../user_preferences_state.dart | 2 +- lib/utils/service_utils.dart | 4 +-- pubspec.lock | 28 +++++++++---------- 87 files changed, 107 insertions(+), 108 deletions(-) rename lib/{components => modules}/album/album_card.dart (100%) rename lib/{components => modules}/artist/artist_album_list.dart (100%) rename lib/{components => modules}/artist/artist_card.dart (100%) rename lib/{components => modules}/connect/connect_device.dart (100%) rename lib/{components => modules}/connect/local_devices.dart (100%) rename lib/{components => modules}/desktop_login/login_form.dart (100%) rename lib/{components => modules}/getting_started/blur_card.dart (100%) rename lib/{components => modules}/home/sections/featured.dart (100%) rename lib/{components => modules}/home/sections/feed.dart (100%) rename lib/{components => modules}/home/sections/friends.dart (97%) rename lib/{components => modules}/home/sections/friends/friend_item.dart (100%) rename lib/{components => modules}/home/sections/genres.dart (100%) rename lib/{components => modules}/home/sections/made_for_user.dart (100%) rename lib/{components => modules}/home/sections/new_releases.dart (100%) rename lib/{components => modules}/home/sections/recent.dart (100%) rename lib/{components => modules}/library/local_folder/local_folder_item.dart (100%) rename lib/{components => modules}/library/playlist_generate/multi_select_field.dart (100%) rename lib/{components => modules}/library/playlist_generate/recommendation_attribute_dials.dart (100%) rename lib/{components => modules}/library/playlist_generate/recommendation_attribute_fields.dart (98%) rename lib/{components => modules}/library/playlist_generate/seeds_multi_autocomplete.dart (100%) rename lib/{components => modules}/library/playlist_generate/simple_track_tile.dart (100%) rename lib/{components => modules}/library/user_albums.dart (98%) rename lib/{components => modules}/library/user_artists.dart (98%) rename lib/{components => modules}/library/user_downloads.dart (96%) rename lib/{components => modules}/library/user_downloads/download_item.dart (100%) rename lib/{components => modules}/library/user_local_tracks.dart (97%) rename lib/{components => modules}/library/user_playlists.dart (97%) rename lib/{components => modules}/lyrics/use_synced_lyrics.dart (100%) rename lib/{components => modules}/lyrics/zoom_controls.dart (100%) rename lib/{components => modules}/player/player.dart (98%) rename lib/{components => modules}/player/player_actions.dart (99%) rename lib/{components => modules}/player/player_controls.dart (99%) rename lib/{components => modules}/player/player_overlay.dart (96%) rename lib/{components => modules}/player/player_queue.dart (100%) rename lib/{components => modules}/player/player_track_details.dart (100%) rename lib/{components => modules}/player/sibling_tracks_sheet.dart (100%) rename lib/{components => modules}/player/use_progress.dart (100%) rename lib/{components => modules}/player/volume_slider.dart (100%) rename lib/{components => modules}/playlist/playlist_card.dart (100%) rename lib/{components => modules}/playlist/playlist_create_dialog.dart (100%) rename lib/{components => modules}/root/bottom_player.dart (94%) rename lib/{components => modules}/root/sidebar.dart (99%) rename lib/{components => modules}/root/spotube_navigation_bar.dart (100%) rename lib/{components => modules}/root/update_dialog.dart (100%) rename lib/{components => modules}/settings/color_scheme_picker_dialog.dart (100%) rename lib/{components => modules}/settings/section_card_with_heading.dart (100%) diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 579aff18..6d6e643e 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -5,7 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:spotube/collections/routes.dart'; -import 'package:spotube/components/player/player_controls.dart'; +import 'package:spotube/modules/player/player_controls.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/library/library.dart'; diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/shared/dialogs/playlist_add_track_dialog.dart index 5d493a68..34617b84 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/shared/dialogs/playlist_add_track_dialog.dart @@ -4,7 +4,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/playlist/playlist_create_dialog.dart'; +import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart index 291950bb..16204952 100644 --- a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart +++ b/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart @@ -5,9 +5,9 @@ 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/modules/album/album_card.dart'; +import 'package:spotube/modules/artist/artist_card.dart'; +import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; diff --git a/lib/components/shared/sort_tracks_dropdown.dart b/lib/components/shared/sort_tracks_dropdown.dart index be72d689..ab27e7ff 100644 --- a/lib/components/shared/sort_tracks_dropdown.dart +++ b/lib/components/shared/sort_tracks_dropdown.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; +import 'package:spotube/modules/library/user_local_tracks.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/shared/tracks_view/sections/header/header_actions.dart index 8c1c8e15..6ea53b83 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/shared/tracks_view/sections/header/header_actions.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/playlist/playlist_create_dialog.dart'; +import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; diff --git a/lib/components/shared/tracks_view/track_view_provider.dart b/lib/components/shared/tracks_view/track_view_provider.dart index 14dc1136..16aa6d9c 100644 --- a/lib/components/shared/tracks_view/track_view_provider.dart +++ b/lib/components/shared/tracks_view/track_view_provider.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; +import 'package:spotube/modules/library/user_local_tracks.dart'; class TrackViewNotifier extends ChangeNotifier { List tracks; diff --git a/lib/components/stats/common/album_item.dart b/lib/components/stats/common/album_item.dart index ccc0fa4e..af53d273 100644 --- a/lib/components/stats/common/album_item.dart +++ b/lib/components/stats/common/album_item.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/album/album_card.dart'; +import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/components/album/album_card.dart b/lib/modules/album/album_card.dart similarity index 100% rename from lib/components/album/album_card.dart rename to lib/modules/album/album_card.dart diff --git a/lib/components/artist/artist_album_list.dart b/lib/modules/artist/artist_album_list.dart similarity index 100% rename from lib/components/artist/artist_album_list.dart rename to lib/modules/artist/artist_album_list.dart diff --git a/lib/components/artist/artist_card.dart b/lib/modules/artist/artist_card.dart similarity index 100% rename from lib/components/artist/artist_card.dart rename to lib/modules/artist/artist_card.dart diff --git a/lib/components/connect/connect_device.dart b/lib/modules/connect/connect_device.dart similarity index 100% rename from lib/components/connect/connect_device.dart rename to lib/modules/connect/connect_device.dart diff --git a/lib/components/connect/local_devices.dart b/lib/modules/connect/local_devices.dart similarity index 100% rename from lib/components/connect/local_devices.dart rename to lib/modules/connect/local_devices.dart diff --git a/lib/components/desktop_login/login_form.dart b/lib/modules/desktop_login/login_form.dart similarity index 100% rename from lib/components/desktop_login/login_form.dart rename to lib/modules/desktop_login/login_form.dart diff --git a/lib/components/getting_started/blur_card.dart b/lib/modules/getting_started/blur_card.dart similarity index 100% rename from lib/components/getting_started/blur_card.dart rename to lib/modules/getting_started/blur_card.dart diff --git a/lib/components/home/sections/featured.dart b/lib/modules/home/sections/featured.dart similarity index 100% rename from lib/components/home/sections/featured.dart rename to lib/modules/home/sections/featured.dart diff --git a/lib/components/home/sections/feed.dart b/lib/modules/home/sections/feed.dart similarity index 100% rename from lib/components/home/sections/feed.dart rename to lib/modules/home/sections/feed.dart diff --git a/lib/components/home/sections/friends.dart b/lib/modules/home/sections/friends.dart similarity index 97% rename from lib/components/home/sections/friends.dart rename to lib/modules/home/sections/friends.dart index 4ae802e6..85325f5a 100644 --- a/lib/components/home/sections/friends.dart +++ b/lib/modules/home/sections/friends.dart @@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/home/sections/friends/friend_item.dart'; +import 'package:spotube/modules/home/sections/friends/friend_item.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/provider/authentication_provider.dart'; diff --git a/lib/components/home/sections/friends/friend_item.dart b/lib/modules/home/sections/friends/friend_item.dart similarity index 100% rename from lib/components/home/sections/friends/friend_item.dart rename to lib/modules/home/sections/friends/friend_item.dart diff --git a/lib/components/home/sections/genres.dart b/lib/modules/home/sections/genres.dart similarity index 100% rename from lib/components/home/sections/genres.dart rename to lib/modules/home/sections/genres.dart diff --git a/lib/components/home/sections/made_for_user.dart b/lib/modules/home/sections/made_for_user.dart similarity index 100% rename from lib/components/home/sections/made_for_user.dart rename to lib/modules/home/sections/made_for_user.dart diff --git a/lib/components/home/sections/new_releases.dart b/lib/modules/home/sections/new_releases.dart similarity index 100% rename from lib/components/home/sections/new_releases.dart rename to lib/modules/home/sections/new_releases.dart diff --git a/lib/components/home/sections/recent.dart b/lib/modules/home/sections/recent.dart similarity index 100% rename from lib/components/home/sections/recent.dart rename to lib/modules/home/sections/recent.dart diff --git a/lib/components/library/local_folder/local_folder_item.dart b/lib/modules/library/local_folder/local_folder_item.dart similarity index 100% rename from lib/components/library/local_folder/local_folder_item.dart rename to lib/modules/library/local_folder/local_folder_item.dart diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/modules/library/playlist_generate/multi_select_field.dart similarity index 100% rename from lib/components/library/playlist_generate/multi_select_field.dart rename to lib/modules/library/playlist_generate/multi_select_field.dart diff --git a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart b/lib/modules/library/playlist_generate/recommendation_attribute_dials.dart similarity index 100% rename from lib/components/library/playlist_generate/recommendation_attribute_dials.dart rename to lib/modules/library/playlist_generate/recommendation_attribute_dials.dart diff --git a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart b/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart similarity index 98% rename from lib/components/library/playlist_generate/recommendation_attribute_fields.dart rename to lib/modules/library/playlist_generate/recommendation_attribute_fields.dart index 75437360..7feff03a 100644 --- a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart +++ b/lib/modules/library/playlist_generate/recommendation_attribute_fields.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/components/library/playlist_generate/recommendation_attribute_dials.dart'; +import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_dials.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; diff --git a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart b/lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart similarity index 100% rename from lib/components/library/playlist_generate/seeds_multi_autocomplete.dart rename to lib/modules/library/playlist_generate/seeds_multi_autocomplete.dart diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/modules/library/playlist_generate/simple_track_tile.dart similarity index 100% rename from lib/components/library/playlist_generate/simple_track_tile.dart rename to lib/modules/library/playlist_generate/simple_track_tile.dart diff --git a/lib/components/library/user_albums.dart b/lib/modules/library/user_albums.dart similarity index 98% rename from lib/components/library/user_albums.dart rename to lib/modules/library/user_albums.dart index e1b82113..b7fcc958 100644 --- a/lib/components/library/user_albums.dart +++ b/lib/modules/library/user_albums.dart @@ -8,7 +8,7 @@ import 'package:skeletonizer/skeletonizer.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/modules/album/album_card.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/waypoint.dart'; diff --git a/lib/components/library/user_artists.dart b/lib/modules/library/user_artists.dart similarity index 98% rename from lib/components/library/user_artists.dart rename to lib/modules/library/user_artists.dart index 0ef0ff39..118447ae 100644 --- a/lib/components/library/user_artists.dart +++ b/lib/modules/library/user_artists.dart @@ -9,7 +9,7 @@ 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/modules/artist/artist_card.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; diff --git a/lib/components/library/user_downloads.dart b/lib/modules/library/user_downloads.dart similarity index 96% rename from lib/components/library/user_downloads.dart rename to lib/modules/library/user_downloads.dart index 3a1162e6..a5f3883a 100644 --- a/lib/components/library/user_downloads.dart +++ b/lib/modules/library/user_downloads.dart @@ -2,7 +2,7 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/library/user_downloads/download_item.dart'; +import 'package:spotube/modules/library/user_downloads/download_item.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/modules/library/user_downloads/download_item.dart similarity index 100% rename from lib/components/library/user_downloads/download_item.dart rename to lib/modules/library/user_downloads/download_item.dart diff --git a/lib/components/library/user_local_tracks.dart b/lib/modules/library/user_local_tracks.dart similarity index 97% rename from lib/components/library/user_local_tracks.dart rename to lib/modules/library/user_local_tracks.dart index c0d63380..926b4e80 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/modules/library/user_local_tracks.dart @@ -6,7 +6,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/local_folder/local_folder_item.dart'; +import 'package:spotube/modules/library/local_folder/local_folder_item.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; diff --git a/lib/components/library/user_playlists.dart b/lib/modules/library/user_playlists.dart similarity index 97% rename from lib/components/library/user_playlists.dart rename to lib/modules/library/user_playlists.dart index 246c3947..104badf6 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/modules/library/user_playlists.dart @@ -9,10 +9,10 @@ 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/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/playlist/playlist_card.dart'; +import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/components/shared/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/components/lyrics/use_synced_lyrics.dart b/lib/modules/lyrics/use_synced_lyrics.dart similarity index 100% rename from lib/components/lyrics/use_synced_lyrics.dart rename to lib/modules/lyrics/use_synced_lyrics.dart diff --git a/lib/components/lyrics/zoom_controls.dart b/lib/modules/lyrics/zoom_controls.dart similarity index 100% rename from lib/components/lyrics/zoom_controls.dart rename to lib/modules/lyrics/zoom_controls.dart diff --git a/lib/components/player/player.dart b/lib/modules/player/player.dart similarity index 98% rename from lib/components/player/player.dart rename to lib/modules/player/player.dart index 49341058..c87b336b 100644 --- a/lib/components/player/player.dart +++ b/lib/modules/player/player.dart @@ -6,10 +6,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/player_actions.dart'; -import 'package:spotube/components/player/player_controls.dart'; -import 'package:spotube/components/player/player_queue.dart'; -import 'package:spotube/components/player/volume_slider.dart'; +import 'package:spotube/modules/player/player_actions.dart'; +import 'package:spotube/modules/player/player_controls.dart'; +import 'package:spotube/modules/player/player_queue.dart'; +import 'package:spotube/modules/player/volume_slider.dart'; import 'package:spotube/components/shared/animated_gradient.dart'; import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; import 'package:spotube/components/shared/links/artist_link.dart'; diff --git a/lib/components/player/player_actions.dart b/lib/modules/player/player_actions.dart similarity index 99% rename from lib/components/player/player_actions.dart rename to lib/modules/player/player_actions.dart index d28c3900..41ee9e39 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/sibling_tracks_sheet.dart'; +import 'package:spotube/modules/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/extensions/artist_simple.dart'; diff --git a/lib/components/player/player_controls.dart b/lib/modules/player/player_controls.dart similarity index 99% rename from lib/components/player/player_controls.dart rename to lib/modules/player/player_controls.dart index 7683de19..ba69560c 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/modules/player/player_controls.dart @@ -8,7 +8,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; -import 'package:spotube/components/player/use_progress.dart'; +import 'package:spotube/modules/player/use_progress.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; diff --git a/lib/components/player/player_overlay.dart b/lib/modules/player/player_overlay.dart similarity index 96% rename from lib/components/player/player_overlay.dart rename to lib/modules/player/player_overlay.dart index 168e022d..7911a046 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/modules/player/player_overlay.dart @@ -4,13 +4,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/player/player_track_details.dart'; -import 'package:spotube/components/root/spotube_navigation_bar.dart'; +import 'package:spotube/modules/player/player_track_details.dart'; +import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; -import 'package:spotube/components/player/use_progress.dart'; -import 'package:spotube/components/player/player.dart'; +import 'package:spotube/modules/player/use_progress.dart'; +import 'package:spotube/modules/player/player.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; diff --git a/lib/components/player/player_queue.dart b/lib/modules/player/player_queue.dart similarity index 100% rename from lib/components/player/player_queue.dart rename to lib/modules/player/player_queue.dart diff --git a/lib/components/player/player_track_details.dart b/lib/modules/player/player_track_details.dart similarity index 100% rename from lib/components/player/player_track_details.dart rename to lib/modules/player/player_track_details.dart diff --git a/lib/components/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart similarity index 100% rename from lib/components/player/sibling_tracks_sheet.dart rename to lib/modules/player/sibling_tracks_sheet.dart diff --git a/lib/components/player/use_progress.dart b/lib/modules/player/use_progress.dart similarity index 100% rename from lib/components/player/use_progress.dart rename to lib/modules/player/use_progress.dart diff --git a/lib/components/player/volume_slider.dart b/lib/modules/player/volume_slider.dart similarity index 100% rename from lib/components/player/volume_slider.dart rename to lib/modules/player/volume_slider.dart diff --git a/lib/components/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart similarity index 100% rename from lib/components/playlist/playlist_card.dart rename to lib/modules/playlist/playlist_card.dart diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/modules/playlist/playlist_create_dialog.dart similarity index 100% rename from lib/components/playlist/playlist_create_dialog.dart rename to lib/modules/playlist/playlist_create_dialog.dart diff --git a/lib/components/root/bottom_player.dart b/lib/modules/root/bottom_player.dart similarity index 94% rename from lib/components/root/bottom_player.dart rename to lib/modules/root/bottom_player.dart index 5429e172..2ab4b14a 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/modules/root/bottom_player.dart @@ -6,11 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/player_actions.dart'; -import 'package:spotube/components/player/player_overlay.dart'; -import 'package:spotube/components/player/player_track_details.dart'; -import 'package:spotube/components/player/player_controls.dart'; -import 'package:spotube/components/player/volume_slider.dart'; +import 'package:spotube/modules/player/player_actions.dart'; +import 'package:spotube/modules/player/player_overlay.dart'; +import 'package:spotube/modules/player/player_track_details.dart'; +import 'package:spotube/modules/player/player_controls.dart'; +import 'package:spotube/modules/player/volume_slider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/components/root/sidebar.dart b/lib/modules/root/sidebar.dart similarity index 99% rename from lib/components/root/sidebar.dart rename to lib/modules/root/sidebar.dart index 8e7374b1..05f14c39 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/modules/root/sidebar.dart @@ -9,7 +9,7 @@ import 'package:sidebarx/sidebarx.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/connect/connect_device.dart'; +import 'package:spotube/modules/connect/connect_device.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/modules/root/spotube_navigation_bar.dart similarity index 100% rename from lib/components/root/spotube_navigation_bar.dart rename to lib/modules/root/spotube_navigation_bar.dart diff --git a/lib/components/root/update_dialog.dart b/lib/modules/root/update_dialog.dart similarity index 100% rename from lib/components/root/update_dialog.dart rename to lib/modules/root/update_dialog.dart diff --git a/lib/components/settings/color_scheme_picker_dialog.dart b/lib/modules/settings/color_scheme_picker_dialog.dart similarity index 100% rename from lib/components/settings/color_scheme_picker_dialog.dart rename to lib/modules/settings/color_scheme_picker_dialog.dart diff --git a/lib/components/settings/section_card_with_heading.dart b/lib/modules/settings/section_card_with_heading.dart similarity index 100% rename from lib/components/settings/section_card_with_heading.dart rename to lib/modules/settings/section_card_with_heading.dart diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 49890949..bd416edd 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -5,7 +5,7 @@ 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/modules/artist/artist_album_list.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/pages/artist/section/footer.dart'; diff --git a/lib/pages/artist/section/related_artists.dart b/lib/pages/artist/section/related_artists.dart index 7fc48ded..066f73fd 100644 --- a/lib/pages/artist/section/related_artists.dart +++ b/lib/pages/artist/section/related_artists.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/artist/artist_card.dart'; +import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPageRelatedArtists extends ConsumerWidget { diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index c7cb493a..a1735d42 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/connect/local_devices.dart'; +import 'package:spotube/modules/connect/local_devices.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/connect/control/control.dart'; diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index 639a9dd9..20ad3d17 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -3,8 +3,8 @@ import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/player_queue.dart'; -import 'package:spotube/components/player/volume_slider.dart'; +import 'package:spotube/modules/player/player_queue.dart'; +import 'package:spotube/modules/player/volume_slider.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/links/anchor_button.dart'; import 'package:spotube/components/shared/links/artist_link.dart'; diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index 9c9bdddb..a5f8c3b1 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -3,7 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/desktop_login/login_form.dart'; +import 'package:spotube/modules/desktop_login/login_form.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index dbec28dc..2c1535fe 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:introduction_screen/introduction_screen.dart'; import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/desktop_login/login_form.dart'; +import 'package:spotube/modules/desktop_login/login_form.dart'; import 'package:spotube/components/shared/links/hyper_link.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/getting_started/sections/greeting.dart b/lib/pages/getting_started/sections/greeting.dart index 563e43de..6d649351 100644 --- a/lib/pages/getting_started/sections/greeting.dart +++ b/lib/pages/getting_started/sections/greeting.dart @@ -3,7 +3,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/getting_started/blur_card.dart'; +import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/pages/getting_started/sections/playback.dart b/lib/pages/getting_started/sections/playback.dart index 298cf839..fab51d06 100644 --- a/lib/pages/getting_started/sections/playback.dart +++ b/lib/pages/getting_started/sections/playback.dart @@ -4,7 +4,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/getting_started/blur_card.dart'; +import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/string.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/pages/getting_started/sections/region.dart b/lib/pages/getting_started/sections/region.dart index 9303392c..0a80fba2 100644 --- a/lib/pages/getting_started/sections/region.dart +++ b/lib/pages/getting_started/sections/region.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/language_codes.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/getting_started/blur_card.dart'; +import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index b6be07e5..b449def5 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -3,7 +3,7 @@ import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/getting_started/blur_card.dart'; +import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart'; diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart index d31b8256..11780620 100644 --- a/lib/pages/home/feed/feed_section.dart +++ b/lib/pages/home/feed/feed_section.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.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/modules/album/album_card.dart'; +import 'package:spotube/modules/artist/artist_card.dart'; +import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/views/home_section.dart'; diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index 531ea889..ef701478 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -5,7 +5,7 @@ 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/modules/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'; diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index d4e2d94e..6ed5c0e4 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -4,14 +4,14 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/connect/connect_device.dart'; -import 'package:spotube/components/home/sections/featured.dart'; -import 'package:spotube/components/home/sections/feed.dart'; -import 'package:spotube/components/home/sections/friends.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/home/sections/recent.dart'; +import 'package:spotube/modules/connect/connect_device.dart'; +import 'package:spotube/modules/home/sections/featured.dart'; +import 'package:spotube/modules/home/sections/feed.dart'; +import 'package:spotube/modules/home/sections/friends.dart'; +import 'package:spotube/modules/home/sections/genres.dart'; +import 'package:spotube/modules/home/sections/made_for_user.dart'; +import 'package:spotube/modules/home/sections/new_releases.dart'; +import 'package:spotube/modules/home/sections/recent.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/pages/settings/settings.dart'; diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index 5385f872..cc96e4ee 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart' hide Image; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; +import 'package:spotube/modules/library/user_local_tracks.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/library/user_albums.dart'; -import 'package:spotube/components/library/user_artists.dart'; -import 'package:spotube/components/library/user_downloads.dart'; -import 'package:spotube/components/library/user_playlists.dart'; +import 'package:spotube/modules/library/user_albums.dart'; +import 'package:spotube/modules/library/user_artists.dart'; +import 'package:spotube/modules/library/user_downloads.dart'; +import 'package:spotube/modules/library/user_playlists.dart'; import 'package:spotube/components/shared/themed_button_tab_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart index ac38e860..27979b5c 100644 --- a/lib/pages/library/local_folder.dart +++ b/lib/pages/library/local_folder.dart @@ -6,7 +6,7 @@ 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/library/user_local_tracks.dart'; +import 'package:spotube/modules/library/user_local_tracks.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'; diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 648e8528..88bf8adb 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -6,11 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/playlist_generate/multi_select_field.dart'; -import 'package:spotube/components/library/playlist_generate/recommendation_attribute_dials.dart'; -import 'package:spotube/components/library/playlist_generate/recommendation_attribute_fields.dart'; -import 'package:spotube/components/library/playlist_generate/seeds_multi_autocomplete.dart'; -import 'package:spotube/components/library/playlist_generate/simple_track_tile.dart'; +import 'package:spotube/modules/library/playlist_generate/multi_select_field.dart'; +import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_dials.dart'; +import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_fields.dart'; +import 'package:spotube/modules/library/playlist_generate/seeds_multi_autocomplete.dart'; +import 'package:spotube/modules/library/playlist_generate/simple_track_tile.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 5ee7ab36..266e9f66 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -4,8 +4,8 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/library/playlist_generate/simple_track_tile.dart'; -import 'package:spotube/components/playlist/playlist_create_dialog.dart'; +import 'package:spotube/modules/library/playlist_generate/simple_track_tile.dart'; +import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 996e190d..f580f56d 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -5,9 +5,9 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/player_controls.dart'; -import 'package:spotube/components/player/player_queue.dart'; -import 'package:spotube/components/root/sidebar.dart'; +import 'package:spotube/modules/player/player_controls.dart'; +import 'package:spotube/modules/player/player_queue.dart'; +import 'package:spotube/modules/root/sidebar.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index b3a55a27..79456e27 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -5,7 +5,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/lyrics/zoom_controls.dart'; +import 'package:spotube/modules/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 0e0fff2e..b0723129 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -4,13 +4,13 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/lyrics/zoom_controls.dart'; +import 'package:spotube/modules/lyrics/zoom_controls.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; -import 'package:spotube/components/lyrics/use_synced_lyrics.dart'; +import 'package:spotube/modules/lyrics/use_synced_lyrics.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 258ecf3c..c2ad64c0 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -7,11 +7,11 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/player/player_queue.dart'; +import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; -import 'package:spotube/components/root/bottom_player.dart'; -import 'package:spotube/components/root/sidebar.dart'; -import 'package:spotube/components/root/spotube_navigation_bar.dart'; +import 'package:spotube/modules/root/bottom_player.dart'; +import 'package:spotube/modules/root/sidebar.dart'; +import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/pages/home/home.dart'; diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index 8b6f7312..81d1b4e5 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index a8d72cc0..531f4a5e 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/shared/adaptive/adaptive_list_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 6162aa3d..46f0e452 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart index 25bd4005..a9283e1a 100644 --- a/lib/pages/settings/sections/appearance.dart +++ b/lib/pages/settings/sections/appearance.dart @@ -3,8 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index 56306868..fa260190 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/pages/settings/sections/developers.dart b/lib/pages/settings/sections/developers.dart index a22cf9f1..f33fe843 100644 --- a/lib/pages/settings/sections/developers.dart +++ b/lib/pages/settings/sections/developers.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; class SettingsDevelopersSection extends HookWidget { diff --git a/lib/pages/settings/sections/downloads.dart b/lib/pages/settings/sections/downloads.dart index 3092ed03..8e679a7d 100644 --- a/lib/pages/settings/sections/downloads.dart +++ b/lib/pages/settings/sections/downloads.dart @@ -3,9 +3,8 @@ import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart index 76670c77..343b7d86 100644 --- a/lib/pages/settings/sections/language_region.dart +++ b/lib/pages/settings/sections/language_region.dart @@ -5,7 +5,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/language_codes.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index eeae98cb..f26135fb 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -6,7 +6,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/settings/section_card_with_heading.dart'; +import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/piped_instances_provider.dart'; diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index fe726915..5825104a 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart index 56f66375..73dd02e8 100644 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ b/lib/provider/user_preferences/user_preferences_state.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/services/sourced_track/enums.dart'; part 'user_preferences_state.g.dart'; diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index aa2cd985..885f9a2c 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -2,8 +2,8 @@ import 'package:dio/dio.dart'; import 'package:go_router/go_router.dart'; import 'package:html/dom.dart' hide Text; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/library/user_local_tracks.dart'; -import 'package:spotube/components/root/update_dialog.dart'; +import 'package:spotube/modules/library/user_local_tracks.dart'; +import 'package:spotube/modules/root/update_dialog.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/lyrics.dart'; import 'package:spotube/services/dio/dio.dart'; diff --git a/pubspec.lock b/pubspec.lock index c11577f2..da410958 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1242,10 +1242,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.18.1" introduction_screen: dependency: "direct main" description: @@ -1298,26 +1298,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.0" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "2.0.1" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "2.0.1" lints: dependency: transitive description: @@ -1458,10 +1458,10 @@ packages: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.0" metadata_god: dependency: "direct main" description: @@ -2146,10 +2146,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.6.1" time: dependency: transitive description: @@ -2354,10 +2354,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "13.0.0" watcher: dependency: transitive description: From b224af21ea625e993a0f08fa2652dd340f87ac9a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 7 Jun 2024 22:34:03 +0600 Subject: [PATCH 123/261] refactor: left out modules --- lib/{components => modules}/stats/common/album_item.dart | 0 lib/{components => modules}/stats/common/artist_item.dart | 0 lib/{components => modules}/stats/common/playlist_item.dart | 0 lib/{components => modules}/stats/common/track_item.dart | 0 lib/{components => modules}/stats/summary/summary.dart | 2 +- lib/{components => modules}/stats/summary/summary_card.dart | 0 lib/{components => modules}/stats/top/albums.dart | 2 +- lib/{components => modules}/stats/top/artists.dart | 2 +- lib/{components => modules}/stats/top/top.dart | 6 +++--- lib/{components => modules}/stats/top/tracks.dart | 2 +- lib/pages/stats/albums/albums.dart | 2 +- lib/pages/stats/artists/artists.dart | 2 +- lib/pages/stats/fees/fees.dart | 2 +- lib/pages/stats/minutes/minutes.dart | 2 +- lib/pages/stats/playlists/playlists.dart | 2 +- lib/pages/stats/stats.dart | 4 ++-- lib/pages/stats/streams/streams.dart | 2 +- 17 files changed, 15 insertions(+), 15 deletions(-) rename lib/{components => modules}/stats/common/album_item.dart (100%) rename lib/{components => modules}/stats/common/artist_item.dart (100%) rename lib/{components => modules}/stats/common/playlist_item.dart (100%) rename lib/{components => modules}/stats/common/track_item.dart (100%) rename lib/{components => modules}/stats/summary/summary.dart (98%) rename lib/{components => modules}/stats/summary/summary_card.dart (100%) rename lib/{components => modules}/stats/top/albums.dart (92%) rename lib/{components => modules}/stats/top/artists.dart (92%) rename lib/{components => modules}/stats/top/top.dart (95%) rename lib/{components => modules}/stats/top/tracks.dart (92%) diff --git a/lib/components/stats/common/album_item.dart b/lib/modules/stats/common/album_item.dart similarity index 100% rename from lib/components/stats/common/album_item.dart rename to lib/modules/stats/common/album_item.dart diff --git a/lib/components/stats/common/artist_item.dart b/lib/modules/stats/common/artist_item.dart similarity index 100% rename from lib/components/stats/common/artist_item.dart rename to lib/modules/stats/common/artist_item.dart diff --git a/lib/components/stats/common/playlist_item.dart b/lib/modules/stats/common/playlist_item.dart similarity index 100% rename from lib/components/stats/common/playlist_item.dart rename to lib/modules/stats/common/playlist_item.dart diff --git a/lib/components/stats/common/track_item.dart b/lib/modules/stats/common/track_item.dart similarity index 100% rename from lib/components/stats/common/track_item.dart rename to lib/modules/stats/common/track_item.dart diff --git a/lib/components/stats/summary/summary.dart b/lib/modules/stats/summary/summary.dart similarity index 98% rename from lib/components/stats/summary/summary.dart rename to lib/modules/stats/summary/summary.dart index 61f3bd6c..0b6c6040 100644 --- a/lib/components/stats/summary/summary.dart +++ b/lib/modules/stats/summary/summary.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/stats/summary/summary_card.dart'; +import 'package:spotube/modules/stats/summary/summary_card.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/pages/stats/albums/albums.dart'; import 'package:spotube/pages/stats/artists/artists.dart'; diff --git a/lib/components/stats/summary/summary_card.dart b/lib/modules/stats/summary/summary_card.dart similarity index 100% rename from lib/components/stats/summary/summary_card.dart rename to lib/modules/stats/summary/summary_card.dart diff --git a/lib/components/stats/top/albums.dart b/lib/modules/stats/top/albums.dart similarity index 92% rename from lib/components/stats/top/albums.dart rename to lib/modules/stats/top/albums.dart index 51bcf5b0..808a58a4 100644 --- a/lib/components/stats/top/albums.dart +++ b/lib/modules/stats/top/albums.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/stats/common/album_item.dart'; +import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/provider/history/top.dart'; class TopAlbums extends HookConsumerWidget { diff --git a/lib/components/stats/top/artists.dart b/lib/modules/stats/top/artists.dart similarity index 92% rename from lib/components/stats/top/artists.dart rename to lib/modules/stats/top/artists.dart index d6d0c98d..24e97601 100644 --- a/lib/components/stats/top/artists.dart +++ b/lib/modules/stats/top/artists.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/provider/history/top.dart'; class TopArtists extends HookConsumerWidget { diff --git a/lib/components/stats/top/top.dart b/lib/modules/stats/top/top.dart similarity index 95% rename from lib/components/stats/top/top.dart rename to lib/modules/stats/top/top.dart index df1275e8..7c384921 100644 --- a/lib/components/stats/top/top.dart +++ b/lib/modules/stats/top/top.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/shared/themed_button_tab_bar.dart'; -import 'package:spotube/components/stats/top/albums.dart'; -import 'package:spotube/components/stats/top/artists.dart'; -import 'package:spotube/components/stats/top/tracks.dart'; +import 'package:spotube/modules/stats/top/albums.dart'; +import 'package:spotube/modules/stats/top/artists.dart'; +import 'package:spotube/modules/stats/top/tracks.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/components/stats/top/tracks.dart b/lib/modules/stats/top/tracks.dart similarity index 92% rename from lib/components/stats/top/tracks.dart rename to lib/modules/stats/top/tracks.dart index bffa4ecd..ee37af3b 100644 --- a/lib/components/stats/top/tracks.dart +++ b/lib/modules/stats/top/tracks.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/provider/history/top.dart'; class TopTracks extends HookConsumerWidget { diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index 83867f93..ecec91b9 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/album_item.dart'; +import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart index 755475ae..b25399c1 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index 228d3243..4993d270 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/artist_item.dart'; +import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index b22f9a4f..0212704a 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -3,7 +3,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index cca7febb..0b4b6cd7 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/playlist_item.dart'; +import 'package:spotube/modules/stats/common/playlist_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart index 95493591..3efd212f 100644 --- a/lib/pages/stats/stats.dart +++ b/lib/pages/stats/stats.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/summary/summary.dart'; -import 'package:spotube/components/stats/top/top.dart'; +import 'package:spotube/modules/stats/summary/summary.dart'; +import 'package:spotube/modules/stats/top/top.dart'; import 'package:spotube/utils/platform.dart'; class StatsPage extends HookConsumerWidget { diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index 33480709..20482929 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -3,7 +3,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/stats/common/track_item.dart'; +import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; From 4af23241c84ebf3079ff1e8b8188d0704d4632d3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 7 Jun 2024 22:40:44 +0600 Subject: [PATCH 124/261] refactor: move shared components to 1 level up --- lib/collections/routes.dart | 2 +- .../{shared => }/adaptive/adaptive_list_tile.dart | 0 .../adaptive/adaptive_pop_sheet_list.dart | 0 .../adaptive/adaptive_popup_menu_button.dart | 0 .../adaptive/adaptive_select_tile.dart | 0 lib/components/{shared => }/animated_gradient.dart | 0 lib/components/{shared => }/bordered_text.dart | 0 lib/components/{shared => }/compact_search.dart | 0 .../dialogs/confirm_download_dialog.dart | 2 +- .../{shared => }/dialogs/piped_down_dialog.dart | 0 .../dialogs/playlist_add_track_dialog.dart | 2 +- .../{shared => }/dialogs/prompt_dialog.dart | 0 .../dialogs/replace_downloaded_dialog.dart | 0 .../{shared => }/dialogs/select_device_dialog.dart | 0 .../{shared => }/dialogs/track_details_dialog.dart | 6 +++--- .../expandable_search/expandable_search.dart | 0 .../{shared => }/fallbacks/anonymous_fallback.dart | 0 .../{shared => }/fallbacks/not_found.dart | 0 lib/components/{shared => }/heart_button.dart | 0 .../horizontal_playbutton_card_view.dart | 0 lib/components/{shared => }/hover_builder.dart | 0 .../{shared => }/image/universal_image.dart | 0 .../inter_scrollbar/inter_scrollbar.dart | 0 .../{shared => }/links/anchor_button.dart | 0 lib/components/{shared => }/links/artist_link.dart | 2 +- lib/components/{shared => }/links/hyper_link.dart | 2 +- lib/components/{shared => }/links/link_text.dart | 2 +- .../{shared => }/page_window_title_bar.dart | 0 lib/components/{shared => }/panels/controller.dart | 2 +- lib/components/{shared => }/panels/helpers.dart | 2 +- .../{shared => }/panels/sliding_up_panel.dart | 0 lib/components/{shared => }/playbutton_card.dart | 4 ++-- .../{shared => }/shimmers/shimmer_lyrics.dart | 0 .../{shared => }/sort_tracks_dropdown.dart | 2 +- .../{shared => }/spotube_page_route.dart | 0 .../{shared => }/themed_button_tab_bar.dart | 0 .../{shared => }/track_tile/track_options.dart | 14 +++++++------- .../{shared => }/track_tile/track_tile.dart | 10 +++++----- .../tracks_view/sections/body/track_view_body.dart | 14 +++++++------- .../sections/body/track_view_body_headers.dart | 10 +++++----- .../sections/body/track_view_options.dart | 10 +++++----- .../sections/body/use_is_user_playlist.dart | 0 .../sections/header/flexible_header.dart | 10 +++++----- .../sections/header/header_actions.dart | 6 +++--- .../sections/header/header_buttons.dart | 4 ++-- .../{shared => }/tracks_view/track_view.dart | 10 +++++----- .../{shared => }/tracks_view/track_view_props.dart | 0 .../tracks_view/track_view_provider.dart | 0 lib/components/{shared => }/waypoint.dart | 0 lib/hooks/utils/use_palette_color.dart | 2 +- lib/modules/album/album_card.dart | 4 ++-- lib/modules/artist/artist_album_list.dart | 2 +- lib/modules/artist/artist_card.dart | 2 +- lib/modules/home/sections/featured.dart | 2 +- lib/modules/home/sections/feed.dart | 2 +- lib/modules/home/sections/friends/friend_item.dart | 2 +- lib/modules/home/sections/genres.dart | 2 +- lib/modules/home/sections/made_for_user.dart | 2 +- lib/modules/home/sections/new_releases.dart | 2 +- lib/modules/home/sections/recent.dart | 2 +- .../library/local_folder/local_folder_item.dart | 2 +- .../playlist_generate/simple_track_tile.dart | 2 +- lib/modules/library/user_albums.dart | 6 +++--- lib/modules/library/user_artists.dart | 6 +++--- .../library/user_downloads/download_item.dart | 4 ++-- lib/modules/library/user_playlists.dart | 6 +++--- lib/modules/player/player.dart | 12 ++++++------ lib/modules/player/player_actions.dart | 4 ++-- lib/modules/player/player_overlay.dart | 2 +- lib/modules/player/player_queue.dart | 6 +++--- lib/modules/player/player_track_details.dart | 6 +++--- lib/modules/player/sibling_tracks_sheet.dart | 4 ++-- lib/modules/playlist/playlist_card.dart | 4 ++-- lib/modules/playlist/playlist_create_dialog.dart | 2 +- lib/modules/root/sidebar.dart | 2 +- lib/modules/root/update_dialog.dart | 2 +- lib/modules/stats/common/album_item.dart | 4 ++-- lib/modules/stats/common/artist_item.dart | 2 +- lib/modules/stats/common/playlist_item.dart | 4 ++-- lib/modules/stats/common/track_item.dart | 4 ++-- lib/modules/stats/top/top.dart | 2 +- lib/pages/album/album.dart | 4 ++-- lib/pages/artist/artist.dart | 2 +- lib/pages/artist/section/footer.dart | 2 +- lib/pages/artist/section/header.dart | 2 +- lib/pages/artist/section/top_tracks.dart | 4 ++-- lib/pages/connect/connect.dart | 2 +- lib/pages/connect/control/control.dart | 8 ++++---- lib/pages/desktop_login/desktop_login.dart | 2 +- lib/pages/desktop_login/login_tutorial.dart | 4 ++-- lib/pages/getting_started/getting_started.dart | 2 +- lib/pages/home/feed/feed_section.dart | 2 +- lib/pages/home/genres/genre_playlists.dart | 6 +++--- lib/pages/home/genres/genres.dart | 2 +- lib/pages/home/home.dart | 2 +- lib/pages/lastfm_login/lastfm_login.dart | 4 ++-- lib/pages/library/library.dart | 4 ++-- lib/pages/library/local_folder.dart | 12 ++++++------ .../playlist_generate/playlist_generate.dart | 4 ++-- .../playlist_generate_result.dart | 4 ++-- lib/pages/lyrics/lyrics.dart | 8 ++++---- lib/pages/lyrics/mini_lyrics.dart | 4 ++-- lib/pages/lyrics/plain_lyrics.dart | 2 +- lib/pages/lyrics/synced_lyrics.dart | 2 +- lib/pages/playlist/liked_playlist.dart | 4 ++-- lib/pages/playlist/playlist.dart | 8 ++++---- lib/pages/profile/profile.dart | 4 ++-- lib/pages/root/root_app.dart | 2 +- lib/pages/search/search.dart | 6 +++--- lib/pages/search/sections/albums.dart | 2 +- lib/pages/search/sections/artists.dart | 2 +- lib/pages/search/sections/playlists.dart | 2 +- lib/pages/search/sections/tracks.dart | 6 +++--- lib/pages/settings/about.dart | 6 +++--- lib/pages/settings/blacklist.dart | 4 ++-- lib/pages/settings/logs.dart | 4 ++-- lib/pages/settings/sections/about.dart | 2 +- lib/pages/settings/sections/accounts.dart | 2 +- lib/pages/settings/sections/appearance.dart | 2 +- lib/pages/settings/sections/desktop.dart | 2 +- lib/pages/settings/sections/language_region.dart | 2 +- lib/pages/settings/sections/playback.dart | 2 +- lib/pages/settings/settings.dart | 2 +- lib/pages/stats/albums/albums.dart | 2 +- lib/pages/stats/artists/artists.dart | 2 +- lib/pages/stats/fees/fees.dart | 2 +- lib/pages/stats/minutes/minutes.dart | 2 +- lib/pages/stats/playlists/playlists.dart | 2 +- lib/pages/stats/stats.dart | 2 +- lib/pages/stats/streams/streams.dart | 2 +- lib/pages/track/track.dart | 12 ++++++------ lib/provider/authentication_provider.dart | 2 +- lib/provider/proxy_playlist/player_listeners.dart | 2 +- lib/utils/persisted_state_notifier.dart | 2 +- 134 files changed, 205 insertions(+), 205 deletions(-) rename lib/components/{shared => }/adaptive/adaptive_list_tile.dart (100%) rename lib/components/{shared => }/adaptive/adaptive_pop_sheet_list.dart (100%) rename lib/components/{shared => }/adaptive/adaptive_popup_menu_button.dart (100%) rename lib/components/{shared => }/adaptive/adaptive_select_tile.dart (100%) rename lib/components/{shared => }/animated_gradient.dart (100%) rename lib/components/{shared => }/bordered_text.dart (100%) rename lib/components/{shared => }/compact_search.dart (100%) rename lib/components/{shared => }/dialogs/confirm_download_dialog.dart (97%) rename lib/components/{shared => }/dialogs/piped_down_dialog.dart (100%) rename lib/components/{shared => }/dialogs/playlist_add_track_dialog.dart (98%) rename lib/components/{shared => }/dialogs/prompt_dialog.dart (100%) rename lib/components/{shared => }/dialogs/replace_downloaded_dialog.dart (100%) rename lib/components/{shared => }/dialogs/select_device_dialog.dart (100%) rename lib/components/{shared => }/dialogs/track_details_dialog.dart (96%) rename lib/components/{shared => }/expandable_search/expandable_search.dart (100%) rename lib/components/{shared => }/fallbacks/anonymous_fallback.dart (100%) rename lib/components/{shared => }/fallbacks/not_found.dart (100%) rename lib/components/{shared => }/heart_button.dart (100%) rename lib/components/{shared => }/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart (100%) rename lib/components/{shared => }/hover_builder.dart (100%) rename lib/components/{shared => }/image/universal_image.dart (100%) rename lib/components/{shared => }/inter_scrollbar/inter_scrollbar.dart (100%) rename lib/components/{shared => }/links/anchor_button.dart (100%) rename lib/components/{shared => }/links/artist_link.dart (96%) rename lib/components/{shared => }/links/hyper_link.dart (92%) rename lib/components/{shared => }/links/link_text.dart (93%) rename lib/components/{shared => }/page_window_title_bar.dart (100%) rename lib/components/{shared => }/panels/controller.dart (99%) rename lib/components/{shared => }/panels/helpers.dart (98%) rename lib/components/{shared => }/panels/sliding_up_panel.dart (100%) rename lib/components/{shared => }/playbutton_card.dart (98%) rename lib/components/{shared => }/shimmers/shimmer_lyrics.dart (100%) rename lib/components/{shared => }/sort_tracks_dropdown.dart (96%) rename lib/components/{shared => }/spotube_page_route.dart (100%) rename lib/components/{shared => }/themed_button_tab_bar.dart (100%) rename lib/components/{shared => }/track_tile/track_options.dart (96%) rename lib/components/{shared => }/track_tile/track_tile.dart (96%) rename lib/components/{shared => }/tracks_view/sections/body/track_view_body.dart (92%) rename lib/components/{shared => }/tracks_view/sections/body/track_view_body_headers.dart (87%) rename lib/components/{shared => }/tracks_view/sections/body/track_view_options.dart (92%) rename lib/components/{shared => }/tracks_view/sections/body/use_is_user_playlist.dart (100%) rename lib/components/{shared => }/tracks_view/sections/header/flexible_header.dart (94%) rename lib/components/{shared => }/tracks_view/sections/header/header_actions.dart (94%) rename lib/components/{shared => }/tracks_view/sections/header/header_buttons.dart (97%) rename lib/components/{shared => }/tracks_view/track_view.dart (77%) rename lib/components/{shared => }/tracks_view/track_view_props.dart (100%) rename lib/components/{shared => }/tracks_view/track_view_provider.dart (100%) rename lib/components/{shared => }/waypoint.dart (100%) diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index dc2e4b7c..e1cc5fb6 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -36,7 +36,7 @@ import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:spotube/components/shared/spotube_page_route.dart'; +import 'package:spotube/components/spotube_page_route.dart'; import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/desktop_login/login_tutorial.dart'; diff --git a/lib/components/shared/adaptive/adaptive_list_tile.dart b/lib/components/adaptive/adaptive_list_tile.dart similarity index 100% rename from lib/components/shared/adaptive/adaptive_list_tile.dart rename to lib/components/adaptive/adaptive_list_tile.dart diff --git a/lib/components/shared/adaptive/adaptive_pop_sheet_list.dart b/lib/components/adaptive/adaptive_pop_sheet_list.dart similarity index 100% rename from lib/components/shared/adaptive/adaptive_pop_sheet_list.dart rename to lib/components/adaptive/adaptive_pop_sheet_list.dart diff --git a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart b/lib/components/adaptive/adaptive_popup_menu_button.dart similarity index 100% rename from lib/components/shared/adaptive/adaptive_popup_menu_button.dart rename to lib/components/adaptive/adaptive_popup_menu_button.dart diff --git a/lib/components/shared/adaptive/adaptive_select_tile.dart b/lib/components/adaptive/adaptive_select_tile.dart similarity index 100% rename from lib/components/shared/adaptive/adaptive_select_tile.dart rename to lib/components/adaptive/adaptive_select_tile.dart diff --git a/lib/components/shared/animated_gradient.dart b/lib/components/animated_gradient.dart similarity index 100% rename from lib/components/shared/animated_gradient.dart rename to lib/components/animated_gradient.dart diff --git a/lib/components/shared/bordered_text.dart b/lib/components/bordered_text.dart similarity index 100% rename from lib/components/shared/bordered_text.dart rename to lib/components/bordered_text.dart diff --git a/lib/components/shared/compact_search.dart b/lib/components/compact_search.dart similarity index 100% rename from lib/components/shared/compact_search.dart rename to lib/components/compact_search.dart diff --git a/lib/components/shared/dialogs/confirm_download_dialog.dart b/lib/components/dialogs/confirm_download_dialog.dart similarity index 97% rename from lib/components/shared/dialogs/confirm_download_dialog.dart rename to lib/components/dialogs/confirm_download_dialog.dart index 486310a7..897c64cb 100644 --- a/lib/components/shared/dialogs/confirm_download_dialog.dart +++ b/lib/components/dialogs/confirm_download_dialog.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/components/shared/dialogs/piped_down_dialog.dart b/lib/components/dialogs/piped_down_dialog.dart similarity index 100% rename from lib/components/shared/dialogs/piped_down_dialog.dart rename to lib/components/dialogs/piped_down_dialog.dart diff --git a/lib/components/shared/dialogs/playlist_add_track_dialog.dart b/lib/components/dialogs/playlist_add_track_dialog.dart similarity index 98% rename from lib/components/shared/dialogs/playlist_add_track_dialog.dart rename to lib/components/dialogs/playlist_add_track_dialog.dart index 34617b84..5af9c9e4 100644 --- a/lib/components/shared/dialogs/playlist_add_track_dialog.dart +++ b/lib/components/dialogs/playlist_add_track_dialog.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/components/shared/dialogs/prompt_dialog.dart b/lib/components/dialogs/prompt_dialog.dart similarity index 100% rename from lib/components/shared/dialogs/prompt_dialog.dart rename to lib/components/dialogs/prompt_dialog.dart diff --git a/lib/components/shared/dialogs/replace_downloaded_dialog.dart b/lib/components/dialogs/replace_downloaded_dialog.dart similarity index 100% rename from lib/components/shared/dialogs/replace_downloaded_dialog.dart rename to lib/components/dialogs/replace_downloaded_dialog.dart diff --git a/lib/components/shared/dialogs/select_device_dialog.dart b/lib/components/dialogs/select_device_dialog.dart similarity index 100% rename from lib/components/shared/dialogs/select_device_dialog.dart rename to lib/components/dialogs/select_device_dialog.dart diff --git a/lib/components/shared/dialogs/track_details_dialog.dart b/lib/components/dialogs/track_details_dialog.dart similarity index 96% rename from lib/components/shared/dialogs/track_details_dialog.dart rename to lib/components/dialogs/track_details_dialog.dart index da2a140b..2495863c 100644 --- a/lib/components/shared/dialogs/track_details_dialog.dart +++ b/lib/components/dialogs/track_details_dialog.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; -import 'package:spotube/components/shared/links/hyper_link.dart'; -import 'package:spotube/components/shared/links/link_text.dart'; +import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/components/links/hyper_link.dart'; +import 'package:spotube/components/links/link_text.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; diff --git a/lib/components/shared/expandable_search/expandable_search.dart b/lib/components/expandable_search/expandable_search.dart similarity index 100% rename from lib/components/shared/expandable_search/expandable_search.dart rename to lib/components/expandable_search/expandable_search.dart diff --git a/lib/components/shared/fallbacks/anonymous_fallback.dart b/lib/components/fallbacks/anonymous_fallback.dart similarity index 100% rename from lib/components/shared/fallbacks/anonymous_fallback.dart rename to lib/components/fallbacks/anonymous_fallback.dart diff --git a/lib/components/shared/fallbacks/not_found.dart b/lib/components/fallbacks/not_found.dart similarity index 100% rename from lib/components/shared/fallbacks/not_found.dart rename to lib/components/fallbacks/not_found.dart diff --git a/lib/components/shared/heart_button.dart b/lib/components/heart_button.dart similarity index 100% rename from lib/components/shared/heart_button.dart rename to lib/components/heart_button.dart diff --git a/lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart b/lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart similarity index 100% rename from lib/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart rename to lib/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart diff --git a/lib/components/shared/hover_builder.dart b/lib/components/hover_builder.dart similarity index 100% rename from lib/components/shared/hover_builder.dart rename to lib/components/hover_builder.dart diff --git a/lib/components/shared/image/universal_image.dart b/lib/components/image/universal_image.dart similarity index 100% rename from lib/components/shared/image/universal_image.dart rename to lib/components/image/universal_image.dart diff --git a/lib/components/shared/inter_scrollbar/inter_scrollbar.dart b/lib/components/inter_scrollbar/inter_scrollbar.dart similarity index 100% rename from lib/components/shared/inter_scrollbar/inter_scrollbar.dart rename to lib/components/inter_scrollbar/inter_scrollbar.dart diff --git a/lib/components/shared/links/anchor_button.dart b/lib/components/links/anchor_button.dart similarity index 100% rename from lib/components/shared/links/anchor_button.dart rename to lib/components/links/anchor_button.dart diff --git a/lib/components/shared/links/artist_link.dart b/lib/components/links/artist_link.dart similarity index 96% rename from lib/components/shared/links/artist_link.dart rename to lib/components/links/artist_link.dart index 5236a061..47ddecd8 100644 --- a/lib/components/shared/links/artist_link.dart +++ b/lib/components/links/artist_link.dart @@ -1,6 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/components/links/anchor_button.dart'; import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/utils/service_utils.dart'; diff --git a/lib/components/shared/links/hyper_link.dart b/lib/components/links/hyper_link.dart similarity index 92% rename from lib/components/shared/links/hyper_link.dart rename to lib/components/links/hyper_link.dart index f84517b4..32d715e0 100644 --- a/lib/components/shared/links/hyper_link.dart +++ b/lib/components/links/hyper_link.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/components/links/anchor_button.dart'; import 'package:url_launcher/url_launcher_string.dart'; class Hyperlink extends StatelessWidget { diff --git a/lib/components/shared/links/link_text.dart b/lib/components/links/link_text.dart similarity index 93% rename from lib/components/shared/links/link_text.dart rename to lib/components/links/link_text.dart index db7b6358..0cab71d0 100644 --- a/lib/components/shared/links/link_text.dart +++ b/lib/components/links/link_text.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/components/links/anchor_button.dart'; import 'package:spotube/utils/service_utils.dart'; class LinkText extends StatelessWidget { diff --git a/lib/components/shared/page_window_title_bar.dart b/lib/components/page_window_title_bar.dart similarity index 100% rename from lib/components/shared/page_window_title_bar.dart rename to lib/components/page_window_title_bar.dart diff --git a/lib/components/shared/panels/controller.dart b/lib/components/panels/controller.dart similarity index 99% rename from lib/components/shared/panels/controller.dart rename to lib/components/panels/controller.dart index 65c2444e..834e9ce6 100644 --- a/lib/components/shared/panels/controller.dart +++ b/lib/components/panels/controller.dart @@ -1,4 +1,4 @@ -part of './sliding_up_panel.dart'; +part of 'sliding_up_panel.dart'; class PanelController extends ChangeNotifier { SlidingUpPanelState? _panelState; diff --git a/lib/components/shared/panels/helpers.dart b/lib/components/panels/helpers.dart similarity index 98% rename from lib/components/shared/panels/helpers.dart rename to lib/components/panels/helpers.dart index 6d0dde31..d79fa97c 100644 --- a/lib/components/shared/panels/helpers.dart +++ b/lib/components/panels/helpers.dart @@ -1,4 +1,4 @@ -part of "./sliding_up_panel.dart"; +part of "sliding_up_panel.dart"; /// if you want to prevent the panel from being dragged using the widget, /// wrap the widget with this diff --git a/lib/components/shared/panels/sliding_up_panel.dart b/lib/components/panels/sliding_up_panel.dart similarity index 100% rename from lib/components/shared/panels/sliding_up_panel.dart rename to lib/components/panels/sliding_up_panel.dart diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/playbutton_card.dart similarity index 98% rename from lib/components/shared/playbutton_card.dart rename to lib/components/playbutton_card.dart index 80a27eb0..ffd91cd2 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/playbutton_card.dart @@ -5,8 +5,8 @@ import 'package:gap/gap.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/hover_builder.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/hover_builder.dart'; +import 'package:spotube/components/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'; diff --git a/lib/components/shared/shimmers/shimmer_lyrics.dart b/lib/components/shimmers/shimmer_lyrics.dart similarity index 100% rename from lib/components/shared/shimmers/shimmer_lyrics.dart rename to lib/components/shimmers/shimmer_lyrics.dart diff --git a/lib/components/shared/sort_tracks_dropdown.dart b/lib/components/sort_tracks_dropdown.dart similarity index 96% rename from lib/components/shared/sort_tracks_dropdown.dart rename to lib/components/sort_tracks_dropdown.dart index ab27e7ff..16727013 100644 --- a/lib/components/shared/sort_tracks_dropdown.dart +++ b/lib/components/sort_tracks_dropdown.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/library/user_local_tracks.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/extensions/context.dart'; class SortTracksDropdown extends StatelessWidget { diff --git a/lib/components/shared/spotube_page_route.dart b/lib/components/spotube_page_route.dart similarity index 100% rename from lib/components/shared/spotube_page_route.dart rename to lib/components/spotube_page_route.dart diff --git a/lib/components/shared/themed_button_tab_bar.dart b/lib/components/themed_button_tab_bar.dart similarity index 100% rename from lib/components/shared/themed_button_tab_bar.dart rename to lib/components/themed_button_tab_bar.dart diff --git a/lib/components/shared/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart similarity index 96% rename from lib/components/shared/track_tile/track_options.dart rename to lib/components/track_tile/track_options.dart index 4b383c47..de604744 100644 --- a/lib/components/shared/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -8,13 +8,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/dialogs/track_details_dialog.dart'; +import 'package:spotube/components/heart_button.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/components/shared/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart similarity index 96% rename from lib/components/shared/track_tile/track_tile.dart rename to lib/components/track_tile/track_tile.dart index e3aea4de..9ba87abe 100644 --- a/lib/components/shared/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -7,11 +7,11 @@ 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'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; -import 'package:spotube/components/shared/links/link_text.dart'; -import 'package:spotube/components/shared/track_tile/track_options.dart'; +import 'package:spotube/components/hover_builder.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/components/links/link_text.dart'; +import 'package:spotube/components/track_tile/track_options.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/tracks_view/sections/body/track_view_body.dart similarity index 92% rename from lib/components/shared/tracks_view/sections/body/track_view_body.dart rename to lib/components/tracks_view/sections/body/track_view_body.dart index c3605f33..0c3cca4e 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/tracks_view/sections/body/track_view_body.dart @@ -8,13 +8,13 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; -import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/expandable_search/expandable_search.dart'; +import 'package:spotube/components/track_tile/track_tile.dart'; +import 'package:spotube/components/tracks_view/sections/body/track_view_body_headers.dart'; +import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/tracks_view/track_view_provider.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/tracks_view/sections/body/track_view_body_headers.dart similarity index 87% rename from lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart rename to lib/components/tracks_view/sections/body/track_view_body_headers.dart index 3a1538a3..564c85d0 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body_headers.dart +++ b/lib/components/tracks_view/sections/body/track_view_body_headers.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; -import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/track_view_options.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/components/expandable_search/expandable_search.dart'; +import 'package:spotube/components/sort_tracks_dropdown.dart'; +import 'package:spotube/components/tracks_view/sections/body/track_view_options.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/tracks_view/track_view_provider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/components/shared/tracks_view/sections/body/track_view_options.dart b/lib/components/tracks_view/sections/body/track_view_options.dart similarity index 92% rename from lib/components/shared/tracks_view/sections/body/track_view_options.dart rename to lib/components/tracks_view/sections/body/track_view_options.dart index c2adf38b..f004b10a 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/tracks_view/sections/body/track_view_options.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart'; -import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/components/dialogs/confirm_download_dialog.dart'; +import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; +import 'package:spotube/components/tracks_view/track_view_provider.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/history/history.dart'; diff --git a/lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart b/lib/components/tracks_view/sections/body/use_is_user_playlist.dart similarity index 100% rename from lib/components/shared/tracks_view/sections/body/use_is_user_playlist.dart rename to lib/components/tracks_view/sections/body/use_is_user_playlist.dart diff --git a/lib/components/shared/tracks_view/sections/header/flexible_header.dart b/lib/components/tracks_view/sections/header/flexible_header.dart similarity index 94% rename from lib/components/shared/tracks_view/sections/header/flexible_header.dart rename to lib/components/tracks_view/sections/header/flexible_header.dart index d6e71e8f..6e8fc2d1 100644 --- a/lib/components/shared/tracks_view/sections/header/flexible_header.dart +++ b/lib/components/tracks_view/sections/header/flexible_header.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/playbutton_card.dart'; -import 'package:spotube/components/shared/tracks_view/sections/header/header_actions.dart'; -import 'package:spotube/components/shared/tracks_view/sections/header/header_buttons.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/playbutton_card.dart'; +import 'package:spotube/components/tracks_view/sections/header/header_actions.dart'; +import 'package:spotube/components/tracks_view/sections/header/header_buttons.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:gap/gap.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; diff --git a/lib/components/shared/tracks_view/sections/header/header_actions.dart b/lib/components/tracks_view/sections/header/header_actions.dart similarity index 94% rename from lib/components/shared/tracks_view/sections/header/header_actions.dart rename to lib/components/tracks_view/sections/header/header_actions.dart index 6ea53b83..a1e959d9 100644 --- a/lib/components/shared/tracks_view/sections/header/header_actions.dart +++ b/lib/components/tracks_view/sections/header/header_actions.dart @@ -5,9 +5,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/shared/heart_button.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/heart_button.dart'; +import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/history/history.dart'; diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/tracks_view/sections/header/header_buttons.dart similarity index 97% rename from lib/components/shared/tracks_view/sections/header/header_buttons.dart rename to lib/components/tracks_view/sections/header/header_buttons.dart index 5cc442cf..aa660f01 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/tracks_view/sections/header/header_buttons.dart @@ -7,8 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; diff --git a/lib/components/shared/tracks_view/track_view.dart b/lib/components/tracks_view/track_view.dart similarity index 77% rename from lib/components/shared/tracks_view/track_view.dart rename to lib/components/tracks_view/track_view.dart index 03d628a8..36d334cd 100644 --- a/lib/components/shared/tracks_view/track_view.dart +++ b/lib/components/tracks_view/track_view.dart @@ -3,11 +3,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:sliver_tools/sliver_tools.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/page_window_title_bar.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'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/tracks_view/sections/header/flexible_header.dart'; +import 'package:spotube/components/tracks_view/sections/body/track_view_body.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/utils/platform.dart'; class TrackView extends HookConsumerWidget { diff --git a/lib/components/shared/tracks_view/track_view_props.dart b/lib/components/tracks_view/track_view_props.dart similarity index 100% rename from lib/components/shared/tracks_view/track_view_props.dart rename to lib/components/tracks_view/track_view_props.dart diff --git a/lib/components/shared/tracks_view/track_view_provider.dart b/lib/components/tracks_view/track_view_provider.dart similarity index 100% rename from lib/components/shared/tracks_view/track_view_provider.dart rename to lib/components/tracks_view/track_view_provider.dart diff --git a/lib/components/shared/waypoint.dart b/lib/components/waypoint.dart similarity index 100% rename from lib/components/shared/waypoint.dart rename to lib/components/waypoint.dart diff --git a/lib/hooks/utils/use_palette_color.dart b/lib/hooks/utils/use_palette_color.dart index e6d8b398..64994d2b 100644 --- a/lib/hooks/utils/use_palette_color.dart +++ b/lib/hooks/utils/use_palette_color.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; final _paletteColorState = StateProvider( (ref) { diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index 7212a574..a071ac04 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/playbutton_card.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/modules/artist/artist_album_list.dart b/lib/modules/artist/artist_album_list.dart index a91327ce..9bb65804 100644 --- a/lib/modules/artist/artist_album_list.dart +++ b/lib/modules/artist/artist_album_list.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart' hide Page; 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/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/modules/artist/artist_card.dart b/lib/modules/artist/artist_card.dart index 57971ada..c1404e42 100644 --- a/lib/modules/artist/artist_card.dart +++ b/lib/modules/artist/artist_card.dart @@ -4,7 +4,7 @@ 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'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; diff --git a/lib/modules/home/sections/featured.dart b/lib/modules/home/sections/featured.dart index 0db5a1e8..4f30c342 100644 --- a/lib/modules/home/sections/featured.dart +++ b/lib/modules/home/sections/featured.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart' hide Page; 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/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/modules/home/sections/feed.dart b/lib/modules/home/sections/feed.dart index f3f632ce..f66f01f2 100644 --- a/lib/modules/home/sections/feed.dart +++ b/lib/modules/home/sections/feed.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/pages/home/feed/feed_section.dart'; import 'package:spotube/provider/spotify/views/home.dart'; import 'package:spotube/utils/service_utils.dart'; diff --git a/lib/modules/home/sections/friends/friend_item.dart b/lib/modules/home/sections/friends/friend_item.dart index 2b575756..bb97af04 100644 --- a/lib/modules/home/sections/friends/friend_item.dart +++ b/lib/modules/home/sections/friends/friend_item.dart @@ -4,7 +4,7 @@ import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/artist/artist.dart'; diff --git a/lib/modules/home/sections/genres.dart b/lib/modules/home/sections/genres.dart index 7dfafd5a..3e12e5e9 100644 --- a/lib/modules/home/sections/genres.dart +++ b/lib/modules/home/sections/genres.dart @@ -10,7 +10,7 @@ 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/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; diff --git a/lib/modules/home/sections/made_for_user.dart b/lib/modules/home/sections/made_for_user.dart index d1d269f6..1b9854d3 100644 --- a/lib/modules/home/sections/made_for_user.dart +++ b/lib/modules/home/sections/made_for_user.dart @@ -1,7 +1,7 @@ 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/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HomeMadeForUserSection extends HookConsumerWidget { diff --git a/lib/modules/home/sections/new_releases.dart b/lib/modules/home/sections/new_releases.dart index 82bc0e8c..08b28138 100644 --- a/lib/modules/home/sections/new_releases.dart +++ b/lib/modules/home/sections/new_releases.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart' hide Page; 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/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/provider/spotify/spotify.dart'; diff --git a/lib/modules/home/sections/recent.dart b/lib/modules/home/sections/recent.dart index 0fc5fadf..5be2fcc2 100644 --- a/lib/modules/home/sections/recent.dart +++ b/lib/modules/home/sections/recent.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/provider/history/recent.dart'; import 'package:spotube/provider/history/state.dart'; diff --git a/lib/modules/library/local_folder/local_folder_item.dart b/lib/modules/library/local_folder/local_folder_item.dart index 72032198..9408d008 100644 --- a/lib/modules/library/local_folder/local_folder_item.dart +++ b/lib/modules/library/local_folder/local_folder_item.dart @@ -6,7 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path/path.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/modules/library/playlist_generate/simple_track_tile.dart b/lib/modules/library/playlist_generate/simple_track_tile.dart index cf4ddb1a..e6cc281f 100644 --- a/lib/modules/library/playlist_generate/simple_track_tile.dart +++ b/lib/modules/library/playlist_generate/simple_track_tile.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; class SimpleTrackTile extends HookWidget { diff --git a/lib/modules/library/user_albums.dart b/lib/modules/library/user_albums.dart index b7fcc958..71e5b65a 100644 --- a/lib/modules/library/user_albums.dart +++ b/lib/modules/library/user_albums.dart @@ -9,9 +9,9 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/album/album_card.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; +import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; diff --git a/lib/modules/library/user_artists.dart b/lib/modules/library/user_artists.dart index 118447ae..dbdd8682 100644 --- a/lib/modules/library/user_artists.dart +++ b/lib/modules/library/user_artists.dart @@ -8,10 +8,10 @@ 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/fallbacks/anonymous_fallback.dart'; import 'package:spotube/modules/artist/artist_card.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; diff --git a/lib/modules/library/user_downloads/download_item.dart b/lib/modules/library/user_downloads/download_item.dart index a145fdad..bc9abf6a 100644 --- a/lib/modules/library/user_downloads/download_item.dart +++ b/lib/modules/library/user_downloads/download_item.dart @@ -3,8 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/download_manager_provider.dart'; diff --git a/lib/modules/library/user_playlists.dart b/lib/modules/library/user_playlists.dart index 104badf6..e0c501bb 100644 --- a/lib/modules/library/user_playlists.dart +++ b/lib/modules/library/user_playlists.dart @@ -10,10 +10,10 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:spotube/components/shared/waypoint.dart'; +import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index c87b336b..7eaf53ae 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -10,12 +10,12 @@ import 'package:spotube/modules/player/player_actions.dart'; import 'package:spotube/modules/player/player_controls.dart'; import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/player/volume_slider.dart'; -import 'package:spotube/components/shared/animated_gradient.dart'; -import 'package:spotube/components/shared/dialogs/track_details_dialog.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; +import 'package:spotube/components/animated_gradient.dart'; +import 'package:spotube/components/dialogs/track_details_dialog.dart'; +import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/panels/sliding_up_panel.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart index 41ee9e39..df366485 100644 --- a/lib/modules/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -5,8 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/player/sibling_tracks_sheet.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/shared/heart_button.dart'; +import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; +import 'package:spotube/components/heart_button.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; diff --git a/lib/modules/player/player_overlay.dart b/lib/modules/player/player_overlay.dart index 7911a046..084de425 100644 --- a/lib/modules/player/player_overlay.dart +++ b/lib/modules/player/player_overlay.dart @@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/modules/player/player_track_details.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart'; -import 'package:spotube/components/shared/panels/sliding_up_panel.dart'; +import 'package:spotube/components/panels/sliding_up_panel.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; import 'package:spotube/modules/player/use_progress.dart'; diff --git a/lib/modules/player/player_queue.dart b/lib/modules/player/player_queue.dart index 914d7bc9..cf16e9a3 100644 --- a/lib/modules/player/player_queue.dart +++ b/lib/modules/player/player_queue.dart @@ -11,9 +11,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/fallbacks/not_found.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/components/fallbacks/not_found.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/track_tile/track_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/modules/player/player_track_details.dart b/lib/modules/player/player_track_details.dart index 4746fe51..da58e3b1 100644 --- a/lib/modules/player/player_track_details.dart +++ b/lib/modules/player/player_track_details.dart @@ -3,9 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; -import 'package:spotube/components/shared/links/link_text.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/components/links/link_text.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/modules/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart index 99b7b430..14731907 100644 --- a/lib/modules/player/sibling_tracks_sheet.dart +++ b/lib/modules/player/sibling_tracks_sheet.dart @@ -7,8 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index 9f26f739..7c11eca6 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/pages/playlist/playlist.dart'; diff --git a/lib/modules/playlist/playlist_create_dialog.dart b/lib/modules/playlist/playlist_create_dialog.dart index bac98b64..6c333986 100644 --- a/lib/modules/playlist/playlist_create_dialog.dart +++ b/lib/modules/playlist/playlist_create_dialog.dart @@ -11,7 +11,7 @@ import 'package:image_picker/image_picker.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/modules/root/sidebar.dart b/lib/modules/root/sidebar.dart index 05f14c39..79d229ef 100644 --- a/lib/modules/root/sidebar.dart +++ b/lib/modules/root/sidebar.dart @@ -10,7 +10,7 @@ import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/connect/connect_device.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/modules/root/update_dialog.dart b/lib/modules/root/update_dialog.dart index e15903c6..4a313096 100644 --- a/lib/modules/root/update_dialog.dart +++ b/lib/modules/root/update_dialog.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/components/links/anchor_button.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:version/version.dart'; diff --git a/lib/modules/stats/common/album_item.dart b/lib/modules/stats/common/album_item.dart index af53d273..0424ca70 100644 --- a/lib/modules/stats/common/album_item.dart +++ b/lib/modules/stats/common/album_item.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/modules/album/album_card.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/pages/album/album.dart'; import 'package:spotube/utils/service_utils.dart'; diff --git a/lib/modules/stats/common/artist_item.dart b/lib/modules/stats/common/artist_item.dart index 9282d4e1..7e7281da 100644 --- a/lib/modules/stats/common/artist_item.dart +++ b/lib/modules/stats/common/artist_item.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/utils/service_utils.dart'; diff --git a/lib/modules/stats/common/playlist_item.dart b/lib/modules/stats/common/playlist_item.dart index b07311ab..79e40d71 100644 --- a/lib/modules/stats/common/playlist_item.dart +++ b/lib/modules/stats/common/playlist_item.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/playbutton_card.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/utils/service_utils.dart'; diff --git a/lib/modules/stats/common/track_item.dart b/lib/modules/stats/common/track_item.dart index 6ba6b886..33991d43 100644 --- a/lib/modules/stats/common/track_item.dart +++ b/lib/modules/stats/common/track_item.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/pages/track/track.dart'; import 'package:spotube/utils/service_utils.dart'; diff --git a/lib/modules/stats/top/top.dart b/lib/modules/stats/top/top.dart index 7c384921..52529f3f 100644 --- a/lib/modules/stats/top/top.dart +++ b/lib/modules/stats/top/top.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/shared/themed_button_tab_bar.dart'; +import 'package:spotube/components/themed_button_tab_bar.dart'; import 'package:spotube/modules/stats/top/albums.dart'; import 'package:spotube/modules/stats/top/artists.dart'; import 'package:spotube/modules/stats/top/tracks.dart'; diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index aea890a0..dcdc2ce7 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/tracks_view/track_view.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/tracks_view/track_view.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index bd416edd..d38fe778 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -4,7 +4,7 @@ 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/page_window_title_bar.dart'; import 'package:spotube/modules/artist/artist_album_list.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; diff --git a/lib/pages/artist/section/footer.dart b/lib/pages/artist/section/footer.dart index 4707b939..abe86410 100644 --- a/lib/pages/artist/section/footer.dart +++ b/lib/pages/artist/section/footer.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 5bad674e..7ca8964d 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -5,7 +5,7 @@ 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/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index 595ac510..c9397c7b 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -4,8 +4,8 @@ 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/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index a1735d42..1e4e3938 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -3,7 +3,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/connect/local_devices.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/provider/connect/clients.dart'; diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index 20ad3d17..afd17387 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -5,10 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/player/volume_slider.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/anchor_button.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/anchor_button.dart'; +import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index a5f8c3b1..1f7cb1b5 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/modules/desktop_login/login_form.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart'; diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index 2c1535fe..d96942e4 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -5,8 +5,8 @@ import 'package:introduction_screen/introduction_screen.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/modules/desktop_login/login_form.dart'; -import 'package:spotube/components/shared/links/hyper_link.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/links/hyper_link.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/authentication_provider.dart'; diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart index fa205403..97af2ef4 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/getting_started/sections/greeting.dart'; import 'package:spotube/pages/getting_started/sections/playback.dart'; diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart index 11780620..42a22f10 100644 --- a/lib/pages/home/feed/feed_section.dart +++ b/lib/pages/home/feed/feed_section.dart @@ -5,7 +5,7 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/views/home_section.dart'; diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index ef701478..4ad37630 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -6,9 +6,9 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/fake.dart'; import 'package:spotube/modules/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/components/image/universal_image.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:collection/collection.dart'; diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index bb84fc16..cf033bb9 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -6,7 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/gradients.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 6ed5c0e4..27b4cc01 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -12,7 +12,7 @@ import 'package:spotube/modules/home/sections/genres.dart'; import 'package:spotube/modules/home/sections/made_for_user.dart'; import 'package:spotube/modules/home/sections/new_releases.dart'; import 'package:spotube/modules/home/sections/recent.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index 2baeaad9..1f4c64e1 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -4,8 +4,8 @@ import 'package:form_validator/form_validator.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index cc96e4ee..66477761 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart' hide Image; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/modules/library/user_local_tracks.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/modules/library/user_albums.dart'; import 'package:spotube/modules/library/user_artists.dart'; import 'package:spotube/modules/library/user_downloads.dart'; import 'package:spotube/modules/library/user_playlists.dart'; -import 'package:spotube/components/shared/themed_button_tab_bar.dart'; +import 'package:spotube/components/themed_button_tab_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart index 27979b5c..648b3c50 100644 --- a/lib/pages/library/local_folder.dart +++ b/lib/pages/library/local_folder.dart @@ -7,12 +7,12 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/library/user_local_tracks.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/page_window_title_bar.dart'; -import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/components/expandable_search/expandable_search.dart'; +import 'package:spotube/components/fallbacks/not_found.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/sort_tracks_dropdown.dart'; +import 'package:spotube/components/track_tile/track_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/local_track.dart'; diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 88bf8adb..74b7fe26 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -11,8 +11,8 @@ import 'package:spotube/modules/library/playlist_generate/recommendation_attribu import 'package:spotube/modules/library/playlist_generate/recommendation_attribute_fields.dart'; import 'package:spotube/modules/library/playlist_generate/seeds_multi_autocomplete.dart'; import 'package:spotube/modules/library/playlist_generate/simple_track_tile.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 266e9f66..a481eac4 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -6,8 +6,8 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/library/playlist_generate/simple_track_tile.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/pages/playlist/playlist.dart'; diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 850eccfa..1a4ea7c1 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -6,10 +6,10 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/themed_button_tab_bar.dart'; +import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/themed_button_tab_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index f580f56d..bd8fc0a1 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -8,8 +8,8 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/player/player_controls.dart'; import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/root/sidebar.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_force_update.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 79456e27..5340e8fd 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/lyrics/zoom_controls.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/components/shimmers/shimmer_lyrics.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index b0723129..8a2dd356 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/lyrics/zoom_controls.dart'; -import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; +import 'package:spotube/components/shimmers/shimmer_lyrics.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/pages/playlist/liked_playlist.dart b/lib/pages/playlist/liked_playlist.dart index 44e99aea..942f46d5 100644 --- a/lib/pages/playlist/liked_playlist.dart +++ b/lib/pages/playlist/liked_playlist.dart @@ -1,8 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/tracks_view/track_view.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/tracks_view/track_view.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 8fb22458..f7c5a431 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; -import 'package:spotube/components/shared/tracks_view/track_view.dart'; -import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; +import 'package:spotube/components/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; +import 'package:spotube/components/tracks_view/track_view.dart'; +import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart index d77ae98d..06f6848a 100644 --- a/lib/pages/profile/profile.dart +++ b/lib/pages/profile/profile.dart @@ -7,8 +7,8 @@ import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:url_launcher/url_launcher_string.dart'; diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index c2ad64c0..6f14a10b 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -8,7 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/player/player_queue.dart'; -import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; +import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/modules/root/bottom_player.dart'; import 'package:spotube/modules/root/sidebar.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart'; diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index d5374786..828e4aef 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -10,9 +10,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_force_update.dart'; diff --git a/lib/pages/search/sections/albums.dart b/lib/pages/search/sections/albums.dart index d15c34ff..857eb59c 100644 --- a/lib/pages/search/sections/albums.dart +++ b/lib/pages/search/sections/albums.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/pages/search/sections/artists.dart b/lib/pages/search/sections/artists.dart index bb8063dc..16295580 100644 --- a/lib/pages/search/sections/artists.dart +++ b/lib/pages/search/sections/artists.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart' hide Page; 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/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/pages/search/sections/playlists.dart b/lib/pages/search/sections/playlists.dart index 13ff483d..3799f9fa 100644 --- a/lib/pages/search/sections/playlists.dart +++ b/lib/pages/search/sections/playlists.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart' hide Page; 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/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index bd7f3c88..1bde2872 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -2,9 +2,9 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/shared/track_tile/track_tile.dart'; +import 'package:spotube/components/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/dialogs/select_device_dialog.dart'; +import 'package:spotube/components/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index e7d95759..2692bfdc 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -2,9 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/env.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/hyper_link.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/hyper_link.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_package_info.dart'; diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index 6eccab07..c30864fe 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -5,8 +5,8 @@ import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index 81d1b4e5..a790c39d 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -5,8 +5,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index 531f4a5e..c1693079 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_list_tile.dart'; +import 'package:spotube/components/adaptive/adaptive_list_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 46f0e452..a007fbeb 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart index a9283e1a..67ed282b 100644 --- a/lib/pages/settings/sections/appearance.dart +++ b/lib/pages/settings/sections/appearance.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; +import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index fa260190..5fbbd8b0 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -3,7 +3,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; +import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart index 343b7d86..c9776fd6 100644 --- a/lib/pages/settings/sections/language_region.dart +++ b/lib/pages/settings/sections/language_region.dart @@ -6,7 +6,7 @@ import 'package:spotube/collections/language_codes.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; +import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/l10n/l10n.dart'; diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index f26135fb..0d37d990 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; -import 'package:spotube/components/shared/adaptive/adaptive_select_tile.dart'; +import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/piped_instances_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index af0fc095..2d1c9224 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/settings/sections/about.dart'; import 'package:spotube/pages/settings/sections/accounts.dart'; diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index ecec91b9..81eba384 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart index b25399c1..f5445326 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index 4993d270..5aa06af9 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index 0212704a..9ce84548 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index 0b4b6cd7..bf1fee93 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/modules/stats/common/playlist_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart index 3efd212f..7dfab844 100644 --- a/lib/pages/stats/stats.dart +++ b/lib/pages/stats/stats.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/modules/stats/summary/summary.dart'; import 'package:spotube/modules/stats/top/top.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index 20482929..12631816 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index 2109fe6e..f797c2f2 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -6,12 +6,12 @@ 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/heart_button.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; -import 'package:spotube/components/shared/links/artist_link.dart'; -import 'package:spotube/components/shared/links/link_text.dart'; -import 'package:spotube/components/shared/page_window_title_bar.dart'; -import 'package:spotube/components/shared/track_tile/track_options.dart'; +import 'package:spotube/components/heart_button.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/links/artist_link.dart'; +import 'package:spotube/components/links/link_text.dart'; +import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/track_tile/track_options.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index be61cb4f..95ce6240 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -8,7 +8,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart' hide X509Certificate; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/routes.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/dialogs/prompt_dialog.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index 3ee815e6..3c36491c 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -4,7 +4,7 @@ import 'dart:async'; import 'package:catcher_2/catcher_2.dart'; import 'package:palette_generator/palette_generator.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/palette_provider.dart'; diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/persisted_state_notifier.dart index 9416a340..450bb664 100644 --- a/lib/utils/persisted_state_notifier.dart +++ b/lib/utils/persisted_state_notifier.dart @@ -6,7 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive/hive.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/dialogs/prompt_dialog.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/primitive_utils.dart'; From 8cb6c6d12638f23dcfb40d0a153c97d14bb0ab0d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 9 Jun 2024 09:19:41 +0600 Subject: [PATCH 125/261] refactor: breakdown page window titlebar widget into multiple small widgets --- .vscode/settings.json | 1 + lib/components/page_window_title_bar.dart | 653 ------------------ lib/components/titlebar/mouse_state.dart | 71 ++ lib/components/titlebar/titlebar.dart | 179 +++++ lib/components/titlebar/titlebar_buttons.dart | 124 ++++ .../titlebar/titlebar_icon_buttons.dart | 161 +++++ lib/components/titlebar/window_button.dart | 133 ++++ lib/components/tracks_view/track_view.dart | 2 +- lib/modules/player/player.dart | 2 +- lib/pages/artist/artist.dart | 2 +- lib/pages/connect/connect.dart | 2 +- lib/pages/connect/control/control.dart | 2 +- lib/pages/desktop_login/desktop_login.dart | 2 +- lib/pages/desktop_login/login_tutorial.dart | 2 +- .../getting_started/getting_started.dart | 2 +- lib/pages/home/feed/feed_section.dart | 2 +- lib/pages/home/genres/genre_playlists.dart | 2 +- lib/pages/home/genres/genres.dart | 2 +- lib/pages/home/home.dart | 2 +- lib/pages/lastfm_login/lastfm_login.dart | 2 +- lib/pages/library/library.dart | 2 +- lib/pages/library/local_folder.dart | 2 +- .../playlist_generate/playlist_generate.dart | 2 +- .../playlist_generate_result.dart | 2 +- lib/pages/lyrics/lyrics.dart | 2 +- lib/pages/lyrics/mini_lyrics.dart | 2 +- lib/pages/profile/profile.dart | 2 +- lib/pages/search/search.dart | 2 +- lib/pages/settings/about.dart | 2 +- lib/pages/settings/blacklist.dart | 2 +- lib/pages/settings/logs.dart | 2 +- lib/pages/settings/settings.dart | 2 +- lib/pages/stats/albums/albums.dart | 2 +- lib/pages/stats/artists/artists.dart | 2 +- lib/pages/stats/fees/fees.dart | 2 +- lib/pages/stats/minutes/minutes.dart | 2 +- lib/pages/stats/playlists/playlists.dart | 2 +- lib/pages/stats/stats.dart | 2 +- lib/pages/stats/streams/streams.dart | 2 +- lib/pages/track/track.dart | 2 +- 40 files changed, 702 insertions(+), 686 deletions(-) delete mode 100644 lib/components/page_window_title_bar.dart create mode 100644 lib/components/titlebar/mouse_state.dart create mode 100644 lib/components/titlebar/titlebar.dart create mode 100644 lib/components/titlebar/titlebar_buttons.dart create mode 100644 lib/components/titlebar/titlebar_icon_buttons.dart create mode 100644 lib/components/titlebar/window_button.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index de5fbd69..0ec6ca76 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,6 +17,7 @@ "songlink", "speechiness", "Spotube", + "titlebar", "winget" ], "editor.formatOnSave": true, diff --git a/lib/components/page_window_title_bar.dart b/lib/components/page_window_title_bar.dart deleted file mode 100644 index c5fc11e7..00000000 --- a/lib/components/page_window_title_bar.dart +++ /dev/null @@ -1,653 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:titlebar_buttons/titlebar_buttons.dart'; -import 'dart:math'; -import 'package:flutter/foundation.dart' show kIsWeb; -import 'dart:io' show Platform; - -import 'package:window_manager/window_manager.dart'; - -class PageWindowTitleBar extends StatefulHookConsumerWidget - implements PreferredSizeWidget { - final Widget? leading; - final bool automaticallyImplyLeading; - final List? actions; - final Color? backgroundColor; - final Color? foregroundColor; - final IconThemeData? actionsIconTheme; - final bool? centerTitle; - final double? titleSpacing; - final double toolbarOpacity; - final double? leadingWidth; - final TextStyle? toolbarTextStyle; - final TextStyle? titleTextStyle; - final double? titleWidth; - final Widget? title; - - final bool _sliver; - - const PageWindowTitleBar({ - super.key, - this.actions, - this.title, - this.toolbarOpacity = 1, - this.backgroundColor, - this.actionsIconTheme, - this.automaticallyImplyLeading = false, - this.centerTitle, - this.foregroundColor, - this.leading, - this.leadingWidth, - this.titleSpacing, - this.titleTextStyle, - this.titleWidth, - this.toolbarTextStyle, - }) : _sliver = false, - pinned = false, - floating = false, - snap = false, - stretch = false; - - final bool pinned; - final bool floating; - final bool snap; - final bool stretch; - - const PageWindowTitleBar.sliver({ - super.key, - this.actions, - this.title, - this.backgroundColor, - this.actionsIconTheme, - this.automaticallyImplyLeading = false, - this.centerTitle, - this.foregroundColor, - this.leading, - this.leadingWidth, - this.titleSpacing, - this.titleTextStyle, - this.titleWidth, - this.toolbarTextStyle, - this.pinned = false, - this.floating = false, - this.snap = false, - this.stretch = false, - }) : _sliver = true, - toolbarOpacity = 1; - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); - - @override - ConsumerState createState() => _PageWindowTitleBarState(); -} - -class _PageWindowTitleBarState extends ConsumerState { - void onDrag(details) { - final systemTitleBar = - ref.read(userPreferencesProvider.select((s) => s.systemTitleBar)); - if (kIsDesktop && !systemTitleBar) { - windowManager.startDragging(); - } - } - - @override - Widget build(BuildContext context) { - final mediaQuery = MediaQuery.of(context); - - if (widget._sliver) { - return SliverLayoutBuilder( - builder: (context, constraints) { - final hasFullscreen = - mediaQuery.size.width == constraints.crossAxisExtent; - final hasLeadingOrCanPop = - widget.leading != null || Navigator.canPop(context); - - return SliverPadding( - padding: EdgeInsets.only( - left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, - ), - sliver: SliverAppBar( - leading: widget.leading, - automaticallyImplyLeading: widget.automaticallyImplyLeading, - actions: [ - ...?widget.actions, - WindowTitleBarButtons(foregroundColor: widget.foregroundColor), - ], - backgroundColor: widget.backgroundColor, - foregroundColor: widget.foregroundColor, - actionsIconTheme: widget.actionsIconTheme, - centerTitle: widget.centerTitle, - titleSpacing: widget.titleSpacing, - leadingWidth: widget.leadingWidth, - toolbarTextStyle: widget.toolbarTextStyle, - titleTextStyle: widget.titleTextStyle, - title: SizedBox( - width: double.infinity, // workaround to force dragging - child: widget.title ?? const Text(""), - ), - pinned: widget.pinned, - floating: widget.floating, - snap: widget.snap, - stretch: widget.stretch, - ), - ); - }, - ); - } - - return LayoutBuilder(builder: (context, constrains) { - final hasFullscreen = mediaQuery.size.width == constrains.maxWidth; - final hasLeadingOrCanPop = - widget.leading != null || Navigator.canPop(context); - - return GestureDetector( - onHorizontalDragStart: onDrag, - onVerticalDragStart: onDrag, - child: Padding( - padding: EdgeInsets.only( - left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, - ), - child: AppBar( - leading: widget.leading, - automaticallyImplyLeading: widget.automaticallyImplyLeading, - actions: [ - ...?widget.actions, - WindowTitleBarButtons(foregroundColor: widget.foregroundColor), - ], - backgroundColor: widget.backgroundColor, - foregroundColor: widget.foregroundColor, - actionsIconTheme: widget.actionsIconTheme, - centerTitle: widget.centerTitle, - titleSpacing: widget.titleSpacing, - toolbarOpacity: widget.toolbarOpacity, - leadingWidth: widget.leadingWidth, - toolbarTextStyle: widget.toolbarTextStyle, - titleTextStyle: widget.titleTextStyle, - title: SizedBox( - width: double.infinity, // workaround to force dragging - child: widget.title ?? const Text(""), - ), - scrolledUnderElevation: 0, - shadowColor: Colors.transparent, - forceMaterialTransparency: true, - elevation: 0, - ), - ), - ); - }); - } -} - -class WindowTitleBarButtons extends HookConsumerWidget { - final Color? foregroundColor; - const WindowTitleBarButtons({ - super.key, - this.foregroundColor, - }); - - @override - Widget build(BuildContext context, ref) { - final preferences = ref.watch(userPreferencesProvider); - final isMaximized = useState(null); - const type = ThemeType.auto; - - Future onClose() async { - await windowManager.close(); - } - - useEffect(() { - if (kIsDesktop) { - windowManager.isMaximized().then((value) { - isMaximized.value = value; - }); - } - return null; - }, []); - - if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) { - return const SizedBox.shrink(); - } - - if (kIsWindows) { - final theme = Theme.of(context); - final colors = WindowButtonColors( - normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onBackground, - mouseOver: theme.colorScheme.onBackground.withOpacity(0.1), - mouseDown: theme.colorScheme.onBackground.withOpacity(0.2), - iconMouseOver: theme.colorScheme.onBackground, - iconMouseDown: theme.colorScheme.onBackground, - ); - - final closeColors = WindowButtonColors( - normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onBackground, - mouseOver: Colors.red, - mouseDown: Colors.red[800]!, - iconMouseOver: Colors.white, - iconMouseDown: Colors.black, - ); - - return Padding( - padding: const EdgeInsets.only(bottom: 25), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MinimizeWindowButton( - onPressed: windowManager.minimize, - colors: colors, - ), - if (isMaximized.value != true) - MaximizeWindowButton( - colors: colors, - onPressed: () { - windowManager.maximize(); - isMaximized.value = true; - }, - ) - else - RestoreWindowButton( - colors: colors, - onPressed: () { - windowManager.unmaximize(); - isMaximized.value = false; - }, - ), - CloseWindowButton( - colors: closeColors, - onPressed: onClose, - ), - ], - ), - ); - } - - return Padding( - padding: const EdgeInsets.only(bottom: 20, left: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DecoratedMinimizeButton( - type: type, - onPressed: windowManager.minimize, - ), - DecoratedMaximizeButton( - type: type, - onPressed: () async { - if (await windowManager.isMaximized()) { - await windowManager.unmaximize(); - isMaximized.value = false; - } else { - await windowManager.maximize(); - isMaximized.value = true; - } - }, - ), - DecoratedCloseButton( - type: type, - onPressed: onClose, - ), - ], - ), - ); - } -} - -typedef WindowButtonIconBuilder = Widget Function( - WindowButtonContext buttonContext); -typedef WindowButtonBuilder = Widget Function( - WindowButtonContext buttonContext, Widget icon); - -class WindowButtonContext { - BuildContext context; - MouseState mouseState; - Color? backgroundColor; - Color iconColor; - WindowButtonContext( - {required this.context, - required this.mouseState, - this.backgroundColor, - required this.iconColor}); -} - -class WindowButtonColors { - late Color normal; - late Color mouseOver; - late Color mouseDown; - late Color iconNormal; - late Color iconMouseOver; - late Color iconMouseDown; - WindowButtonColors( - {Color? normal, - Color? mouseOver, - Color? mouseDown, - Color? iconNormal, - Color? iconMouseOver, - Color? iconMouseDown}) { - this.normal = normal ?? _defaultButtonColors.normal; - this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver; - this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown; - this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal; - this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver; - this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown; - } -} - -final _defaultButtonColors = WindowButtonColors( - normal: Colors.transparent, - iconNormal: const Color(0xFF805306), - mouseOver: const Color(0xFF404040), - mouseDown: const Color(0xFF202020), - iconMouseOver: const Color(0xFFFFFFFF), - iconMouseDown: const Color(0xFFF0F0F0), -); - -class WindowButton extends StatelessWidget { - final WindowButtonBuilder? builder; - final WindowButtonIconBuilder? iconBuilder; - late final WindowButtonColors colors; - final bool animate; - final EdgeInsets? padding; - final VoidCallback? onPressed; - - WindowButton( - {super.key, - WindowButtonColors? colors, - this.builder, - @required this.iconBuilder, - this.padding, - this.onPressed, - this.animate = false}) { - this.colors = colors ?? _defaultButtonColors; - } - - Color getBackgroundColor(MouseState mouseState) { - if (mouseState.isMouseDown) return colors.mouseDown; - if (mouseState.isMouseOver) return colors.mouseOver; - return colors.normal; - } - - Color getIconColor(MouseState mouseState) { - if (mouseState.isMouseDown) return colors.iconMouseDown; - if (mouseState.isMouseOver) return colors.iconMouseOver; - return colors.iconNormal; - } - - @override - Widget build(BuildContext context) { - if (kIsWeb) { - return Container(); - } else { - // Don't show button on macOS - if (Platform.isMacOS) { - return Container(); - } - } - - return MouseStateBuilder( - builder: (context, mouseState) { - WindowButtonContext buttonContext = WindowButtonContext( - mouseState: mouseState, - context: context, - backgroundColor: getBackgroundColor(mouseState), - iconColor: getIconColor(mouseState)); - - var icon = - (iconBuilder != null) ? iconBuilder!(buttonContext) : Container(); - - var fadeOutColor = - getBackgroundColor(MouseState()..isMouseOver = true).withOpacity(0); - var padding = this.padding ?? const EdgeInsets.all(10); - var animationMs = - mouseState.isMouseOver ? (animate ? 100 : 0) : (animate ? 200 : 0); - Widget iconWithPadding = Padding(padding: padding, child: icon); - iconWithPadding = AnimatedContainer( - curve: Curves.easeOut, - duration: Duration(milliseconds: animationMs), - color: buttonContext.backgroundColor ?? fadeOutColor, - child: iconWithPadding); - var button = - (builder != null) ? builder!(buttonContext, icon) : iconWithPadding; - return SizedBox( - width: 45, - height: 32, - child: button, - ); - }, - onPressed: () { - if (onPressed != null) onPressed!(); - }, - ); - } -} - -class MinimizeWindowButton extends WindowButton { - MinimizeWindowButton( - {super.key, super.colors, super.onPressed, bool? animate}) - : super( - animate: animate ?? false, - iconBuilder: (buttonContext) => - MinimizeIcon(color: buttonContext.iconColor), - ); -} - -class MaximizeWindowButton extends WindowButton { - MaximizeWindowButton( - {super.key, super.colors, super.onPressed, bool? animate}) - : super( - animate: animate ?? false, - iconBuilder: (buttonContext) => - MaximizeIcon(color: buttonContext.iconColor), - ); -} - -class RestoreWindowButton extends WindowButton { - RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate}) - : super( - animate: animate ?? false, - iconBuilder: (buttonContext) => - RestoreIcon(color: buttonContext.iconColor), - ); -} - -final _defaultCloseButtonColors = WindowButtonColors( - mouseOver: const Color(0xFFD32F2F), - mouseDown: const Color(0xFFB71C1C), - iconNormal: const Color(0xFF805306), - iconMouseOver: const Color(0xFFFFFFFF)); - -class CloseWindowButton extends WindowButton { - CloseWindowButton( - {super.key, WindowButtonColors? colors, super.onPressed, bool? animate}) - : super( - colors: colors ?? _defaultCloseButtonColors, - animate: animate ?? false, - iconBuilder: (buttonContext) => - CloseIcon(color: buttonContext.iconColor), - ); -} - -// Switched to CustomPaint icons by https://github.com/esDotDev - -/// Close -class CloseIcon extends StatelessWidget { - final Color color; - const CloseIcon({super.key, required this.color}); - @override - Widget build(BuildContext context) => Align( - alignment: Alignment.topLeft, - child: Stack(children: [ - // Use rotated containers instead of a painter because it renders slightly crisper than a painter for some reason. - Transform.rotate( - angle: pi * .25, - child: - Center(child: Container(width: 14, height: 1, color: color))), - Transform.rotate( - angle: pi * -.25, - child: - Center(child: Container(width: 14, height: 1, color: color))), - ]), - ); -} - -/// Maximize -class MaximizeIcon extends StatelessWidget { - final Color color; - const MaximizeIcon({super.key, required this.color}); - @override - Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color)); -} - -class _MaximizePainter extends _IconPainter { - _MaximizePainter(super.color); - @override - void paint(Canvas canvas, Size size) { - Paint p = getPaint(color); - canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p); - } -} - -/// Restore -class RestoreIcon extends StatelessWidget { - final Color color; - const RestoreIcon({ - super.key, - required this.color, - }); - @override - Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color)); -} - -class _RestorePainter extends _IconPainter { - _RestorePainter(super.color); - @override - void paint(Canvas canvas, Size size) { - Paint p = getPaint(color); - canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p); - canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p); - canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p); - canvas.drawLine( - Offset(size.width, 0), Offset(size.width, size.height - 2), p); - canvas.drawLine(Offset(size.width, size.height - 2), - Offset(size.width - 2, size.height - 2), p); - } -} - -/// Minimize -class MinimizeIcon extends StatelessWidget { - final Color color; - const MinimizeIcon({super.key, required this.color}); - @override - Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color)); -} - -class _MinimizePainter extends _IconPainter { - _MinimizePainter(super.color); - @override - void paint(Canvas canvas, Size size) { - Paint p = getPaint(color); - canvas.drawLine( - Offset(0, size.height / 2), Offset(size.width, size.height / 2), p); - } -} - -/// Helpers -abstract class _IconPainter extends CustomPainter { - _IconPainter(this.color); - final Color color; - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -class _AlignedPaint extends StatelessWidget { - const _AlignedPaint(this.painter); - final CustomPainter painter; - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.center, - child: CustomPaint(size: const Size(10, 10), painter: painter)); - } -} - -Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint() - ..color = color - ..style = PaintingStyle.stroke - ..isAntiAlias = isAntiAlias - ..strokeWidth = 1; - -typedef MouseStateBuilderCB = Widget Function( - BuildContext context, MouseState mouseState); - -class MouseState { - bool isMouseOver = false; - bool isMouseDown = false; - MouseState(); - @override - String toString() { - return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver"; - } -} - -T? _ambiguate(T? value) => value; - -class MouseStateBuilder extends StatefulWidget { - final MouseStateBuilderCB builder; - final VoidCallback? onPressed; - const MouseStateBuilder({super.key, required this.builder, this.onPressed}); - @override - // ignore: library_private_types_in_public_api - _MouseStateBuilderState createState() => _MouseStateBuilderState(); -} - -class _MouseStateBuilderState extends State { - late MouseState _mouseState; - _MouseStateBuilderState() { - _mouseState = MouseState(); - } - - @override - Widget build(BuildContext context) { - return MouseRegion( - onEnter: (event) { - setState(() { - _mouseState.isMouseOver = true; - }); - }, - onExit: (event) { - setState(() { - _mouseState.isMouseOver = false; - }); - }, - child: GestureDetector( - onTapDown: (_) { - setState(() { - _mouseState.isMouseDown = true; - }); - }, - onTapCancel: () { - setState(() { - _mouseState.isMouseDown = false; - }); - }, - onTap: () { - setState(() { - _mouseState.isMouseDown = false; - _mouseState.isMouseOver = false; - }); - _ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) { - if (widget.onPressed != null) { - widget.onPressed!(); - } - }); - }, - onTapUp: (_) {}, - child: widget.builder(context, _mouseState))); - } -} diff --git a/lib/components/titlebar/mouse_state.dart b/lib/components/titlebar/mouse_state.dart new file mode 100644 index 00000000..726c6595 --- /dev/null +++ b/lib/components/titlebar/mouse_state.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +typedef MouseStateBuilderCB = Widget Function( + BuildContext context, MouseState mouseState); + +class MouseState { + bool isMouseOver = false; + bool isMouseDown = false; + MouseState(); + @override + String toString() { + return "isMouseDown: $isMouseDown - isMouseOver: $isMouseOver"; + } +} + +T? _ambiguate(T? value) => value; + +class MouseStateBuilder extends StatefulWidget { + final MouseStateBuilderCB builder; + final VoidCallback? onPressed; + const MouseStateBuilder({super.key, required this.builder, this.onPressed}); + @override + // ignore: library_private_types_in_public_api + _MouseStateBuilderState createState() => _MouseStateBuilderState(); +} + +class _MouseStateBuilderState extends State { + late MouseState _mouseState; + _MouseStateBuilderState() { + _mouseState = MouseState(); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (event) { + setState(() { + _mouseState.isMouseOver = true; + }); + }, + onExit: (event) { + setState(() { + _mouseState.isMouseOver = false; + }); + }, + child: GestureDetector( + onTapDown: (_) { + setState(() { + _mouseState.isMouseDown = true; + }); + }, + onTapCancel: () { + setState(() { + _mouseState.isMouseDown = false; + }); + }, + onTap: () { + setState(() { + _mouseState.isMouseDown = false; + _mouseState.isMouseOver = false; + }); + _ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) { + if (widget.onPressed != null) { + widget.onPressed!(); + } + }); + }, + onTapUp: (_) {}, + child: widget.builder(context, _mouseState))); + } +} diff --git a/lib/components/titlebar/titlebar.dart b/lib/components/titlebar/titlebar.dart new file mode 100644 index 00000000..76a5ec8a --- /dev/null +++ b/lib/components/titlebar/titlebar.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/titlebar/titlebar_buttons.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; + +import 'package:window_manager/window_manager.dart'; + +class PageWindowTitleBar extends StatefulHookConsumerWidget + implements PreferredSizeWidget { + final Widget? leading; + final bool automaticallyImplyLeading; + final List? actions; + final Color? backgroundColor; + final Color? foregroundColor; + final IconThemeData? actionsIconTheme; + final bool? centerTitle; + final double? titleSpacing; + final double toolbarOpacity; + final double? leadingWidth; + final TextStyle? toolbarTextStyle; + final TextStyle? titleTextStyle; + final double? titleWidth; + final Widget? title; + + final bool _sliver; + + const PageWindowTitleBar({ + super.key, + this.actions, + this.title, + this.toolbarOpacity = 1, + this.backgroundColor, + this.actionsIconTheme, + this.automaticallyImplyLeading = false, + this.centerTitle, + this.foregroundColor, + this.leading, + this.leadingWidth, + this.titleSpacing, + this.titleTextStyle, + this.titleWidth, + this.toolbarTextStyle, + }) : _sliver = false, + pinned = false, + floating = false, + snap = false, + stretch = false; + + final bool pinned; + final bool floating; + final bool snap; + final bool stretch; + + const PageWindowTitleBar.sliver({ + super.key, + this.actions, + this.title, + this.backgroundColor, + this.actionsIconTheme, + this.automaticallyImplyLeading = false, + this.centerTitle, + this.foregroundColor, + this.leading, + this.leadingWidth, + this.titleSpacing, + this.titleTextStyle, + this.titleWidth, + this.toolbarTextStyle, + this.pinned = false, + this.floating = false, + this.snap = false, + this.stretch = false, + }) : _sliver = true, + toolbarOpacity = 1; + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + ConsumerState createState() => _PageWindowTitleBarState(); +} + +class _PageWindowTitleBarState extends ConsumerState { + void onDrag(details) { + final systemTitleBar = + ref.read(userPreferencesProvider.select((s) => s.systemTitleBar)); + if (kIsDesktop && !systemTitleBar) { + windowManager.startDragging(); + } + } + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + + if (widget._sliver) { + return SliverLayoutBuilder( + builder: (context, constraints) { + final hasFullscreen = + mediaQuery.size.width == constraints.crossAxisExtent; + final hasLeadingOrCanPop = + widget.leading != null || Navigator.canPop(context); + + return SliverPadding( + padding: EdgeInsets.only( + left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, + ), + sliver: SliverAppBar( + leading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + actions: [ + ...?widget.actions, + WindowTitleBarButtons(foregroundColor: widget.foregroundColor), + ], + backgroundColor: widget.backgroundColor, + foregroundColor: widget.foregroundColor, + actionsIconTheme: widget.actionsIconTheme, + centerTitle: widget.centerTitle, + titleSpacing: widget.titleSpacing, + leadingWidth: widget.leadingWidth, + toolbarTextStyle: widget.toolbarTextStyle, + titleTextStyle: widget.titleTextStyle, + title: SizedBox( + width: double.infinity, // workaround to force dragging + child: widget.title ?? const Text(""), + ), + pinned: widget.pinned, + floating: widget.floating, + snap: widget.snap, + stretch: widget.stretch, + ), + ); + }, + ); + } + + return LayoutBuilder(builder: (context, constrains) { + final hasFullscreen = mediaQuery.size.width == constrains.maxWidth; + final hasLeadingOrCanPop = + widget.leading != null || Navigator.canPop(context); + + return GestureDetector( + onHorizontalDragStart: onDrag, + onVerticalDragStart: onDrag, + child: Padding( + padding: EdgeInsets.only( + left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0, + ), + child: AppBar( + leading: widget.leading, + automaticallyImplyLeading: widget.automaticallyImplyLeading, + actions: [ + ...?widget.actions, + WindowTitleBarButtons(foregroundColor: widget.foregroundColor), + ], + backgroundColor: widget.backgroundColor, + foregroundColor: widget.foregroundColor, + actionsIconTheme: widget.actionsIconTheme, + centerTitle: widget.centerTitle, + titleSpacing: widget.titleSpacing, + toolbarOpacity: widget.toolbarOpacity, + leadingWidth: widget.leadingWidth, + toolbarTextStyle: widget.toolbarTextStyle, + titleTextStyle: widget.titleTextStyle, + title: SizedBox( + width: double.infinity, // workaround to force dragging + child: widget.title ?? const Text(""), + ), + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + forceMaterialTransparency: true, + elevation: 0, + ), + ), + ); + }); + } +} diff --git a/lib/components/titlebar/titlebar_buttons.dart b/lib/components/titlebar/titlebar_buttons.dart new file mode 100644 index 00000000..425bf2f1 --- /dev/null +++ b/lib/components/titlebar/titlebar_buttons.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/titlebar/titlebar_icon_buttons.dart'; +import 'package:spotube/components/titlebar/window_button.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:titlebar_buttons/titlebar_buttons.dart'; +import 'package:window_manager/window_manager.dart'; + +class WindowTitleBarButtons extends HookConsumerWidget { + final Color? foregroundColor; + const WindowTitleBarButtons({ + super.key, + this.foregroundColor, + }); + + @override + Widget build(BuildContext context, ref) { + final preferences = ref.watch(userPreferencesProvider); + final isMaximized = useState(null); + const type = ThemeType.auto; + + Future onClose() async { + await windowManager.close(); + } + + useEffect(() { + if (kIsDesktop) { + windowManager.isMaximized().then((value) { + isMaximized.value = value; + }); + } + return null; + }, []); + + if (!kIsDesktop || kIsMacOS || preferences.systemTitleBar) { + return const SizedBox.shrink(); + } + + if (kIsWindows) { + final theme = Theme.of(context); + final colors = WindowButtonColors( + normal: Colors.transparent, + iconNormal: foregroundColor ?? theme.colorScheme.onBackground, + mouseOver: theme.colorScheme.onBackground.withOpacity(0.1), + mouseDown: theme.colorScheme.onBackground.withOpacity(0.2), + iconMouseOver: theme.colorScheme.onBackground, + iconMouseDown: theme.colorScheme.onBackground, + ); + + final closeColors = WindowButtonColors( + normal: Colors.transparent, + iconNormal: foregroundColor ?? theme.colorScheme.onBackground, + mouseOver: Colors.red, + mouseDown: Colors.red[800]!, + iconMouseOver: Colors.white, + iconMouseDown: Colors.black, + ); + + return Padding( + padding: const EdgeInsets.only(bottom: 25), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MinimizeWindowButton( + onPressed: windowManager.minimize, + colors: colors, + ), + if (isMaximized.value != true) + MaximizeWindowButton( + colors: colors, + onPressed: () { + windowManager.maximize(); + isMaximized.value = true; + }, + ) + else + RestoreWindowButton( + colors: colors, + onPressed: () { + windowManager.unmaximize(); + isMaximized.value = false; + }, + ), + CloseWindowButton( + colors: closeColors, + onPressed: onClose, + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 20, left: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DecoratedMinimizeButton( + type: type, + onPressed: windowManager.minimize, + ), + DecoratedMaximizeButton( + type: type, + onPressed: () async { + if (await windowManager.isMaximized()) { + await windowManager.unmaximize(); + isMaximized.value = false; + } else { + await windowManager.maximize(); + isMaximized.value = true; + } + }, + ), + DecoratedCloseButton( + type: type, + onPressed: onClose, + ), + ], + ), + ); + } +} diff --git a/lib/components/titlebar/titlebar_icon_buttons.dart b/lib/components/titlebar/titlebar_icon_buttons.dart new file mode 100644 index 00000000..70170262 --- /dev/null +++ b/lib/components/titlebar/titlebar_icon_buttons.dart @@ -0,0 +1,161 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:spotube/components/titlebar/window_button.dart'; + +class MinimizeWindowButton extends WindowButton { + MinimizeWindowButton( + {super.key, super.colors, super.onPressed, bool? animate}) + : super( + animate: animate ?? false, + iconBuilder: (buttonContext) => + MinimizeIcon(color: buttonContext.iconColor), + ); +} + +class MaximizeWindowButton extends WindowButton { + MaximizeWindowButton( + {super.key, super.colors, super.onPressed, bool? animate}) + : super( + animate: animate ?? false, + iconBuilder: (buttonContext) => + MaximizeIcon(color: buttonContext.iconColor), + ); +} + +class RestoreWindowButton extends WindowButton { + RestoreWindowButton({super.key, super.colors, super.onPressed, bool? animate}) + : super( + animate: animate ?? false, + iconBuilder: (buttonContext) => + RestoreIcon(color: buttonContext.iconColor), + ); +} + +final _defaultCloseButtonColors = WindowButtonColors( + mouseOver: const Color(0xFFD32F2F), + mouseDown: const Color(0xFFB71C1C), + iconNormal: const Color(0xFF805306), + iconMouseOver: const Color(0xFFFFFFFF)); + +class CloseWindowButton extends WindowButton { + CloseWindowButton( + {super.key, WindowButtonColors? colors, super.onPressed, bool? animate}) + : super( + colors: colors ?? _defaultCloseButtonColors, + animate: animate ?? false, + iconBuilder: (buttonContext) => + CloseIcon(color: buttonContext.iconColor), + ); +} + +// Switched to CustomPaint icons by https://github.com/esDotDev + +/// Close +class CloseIcon extends StatelessWidget { + final Color color; + const CloseIcon({super.key, required this.color}); + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.topLeft, + child: Stack(children: [ + // Use rotated containers instead of a painter because it renders slightly crisper than a painter for some reason. + Transform.rotate( + angle: pi * .25, + child: + Center(child: Container(width: 14, height: 1, color: color))), + Transform.rotate( + angle: pi * -.25, + child: + Center(child: Container(width: 14, height: 1, color: color))), + ]), + ); +} + +/// Maximize +class MaximizeIcon extends StatelessWidget { + final Color color; + const MaximizeIcon({super.key, required this.color}); + @override + Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color)); +} + +class _MaximizePainter extends _IconPainter { + _MaximizePainter(super.color); + @override + void paint(Canvas canvas, Size size) { + Paint p = getPaint(color); + canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p); + } +} + +/// Restore +class RestoreIcon extends StatelessWidget { + final Color color; + const RestoreIcon({ + super.key, + required this.color, + }); + @override + Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color)); +} + +class _RestorePainter extends _IconPainter { + _RestorePainter(super.color); + @override + void paint(Canvas canvas, Size size) { + Paint p = getPaint(color); + canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p); + canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p); + canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p); + canvas.drawLine( + Offset(size.width, 0), Offset(size.width, size.height - 2), p); + canvas.drawLine(Offset(size.width, size.height - 2), + Offset(size.width - 2, size.height - 2), p); + } +} + +/// Minimize +class MinimizeIcon extends StatelessWidget { + final Color color; + const MinimizeIcon({super.key, required this.color}); + @override + Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color)); +} + +class _MinimizePainter extends _IconPainter { + _MinimizePainter(super.color); + @override + void paint(Canvas canvas, Size size) { + Paint p = getPaint(color); + canvas.drawLine( + Offset(0, size.height / 2), Offset(size.width, size.height / 2), p); + } +} + +/// Helpers +abstract class _IconPainter extends CustomPainter { + _IconPainter(this.color); + final Color color; + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +class _AlignedPaint extends StatelessWidget { + const _AlignedPaint(this.painter); + final CustomPainter painter; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.center, + child: CustomPaint(size: const Size(10, 10), painter: painter)); + } +} + +Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint() + ..color = color + ..style = PaintingStyle.stroke + ..isAntiAlias = isAntiAlias + ..strokeWidth = 1; diff --git a/lib/components/titlebar/window_button.dart b/lib/components/titlebar/window_button.dart new file mode 100644 index 00000000..3201d191 --- /dev/null +++ b/lib/components/titlebar/window_button.dart @@ -0,0 +1,133 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:spotube/components/titlebar/mouse_state.dart'; + +typedef WindowButtonIconBuilder = Widget Function( + WindowButtonContext buttonContext); +typedef WindowButtonBuilder = Widget Function( + WindowButtonContext buttonContext, Widget icon); + +class WindowButtonContext { + BuildContext context; + MouseState mouseState; + Color? backgroundColor; + Color iconColor; + WindowButtonContext( + {required this.context, + required this.mouseState, + this.backgroundColor, + required this.iconColor}); +} + +class WindowButtonColors { + late Color normal; + late Color mouseOver; + late Color mouseDown; + late Color iconNormal; + late Color iconMouseOver; + late Color iconMouseDown; + WindowButtonColors( + {Color? normal, + Color? mouseOver, + Color? mouseDown, + Color? iconNormal, + Color? iconMouseOver, + Color? iconMouseDown}) { + this.normal = normal ?? _defaultButtonColors.normal; + this.mouseOver = mouseOver ?? _defaultButtonColors.mouseOver; + this.mouseDown = mouseDown ?? _defaultButtonColors.mouseDown; + this.iconNormal = iconNormal ?? _defaultButtonColors.iconNormal; + this.iconMouseOver = iconMouseOver ?? _defaultButtonColors.iconMouseOver; + this.iconMouseDown = iconMouseDown ?? _defaultButtonColors.iconMouseDown; + } +} + +final _defaultButtonColors = WindowButtonColors( + normal: Colors.transparent, + iconNormal: const Color(0xFF805306), + mouseOver: const Color(0xFF404040), + mouseDown: const Color(0xFF202020), + iconMouseOver: const Color(0xFFFFFFFF), + iconMouseDown: const Color(0xFFF0F0F0), +); + +class WindowButton extends StatelessWidget { + final WindowButtonBuilder? builder; + final WindowButtonIconBuilder? iconBuilder; + late final WindowButtonColors colors; + final bool animate; + final EdgeInsets? padding; + final VoidCallback? onPressed; + + WindowButton( + {super.key, + WindowButtonColors? colors, + this.builder, + @required this.iconBuilder, + this.padding, + this.onPressed, + this.animate = false}) { + this.colors = colors ?? _defaultButtonColors; + } + + Color getBackgroundColor(MouseState mouseState) { + if (mouseState.isMouseDown) return colors.mouseDown; + if (mouseState.isMouseOver) return colors.mouseOver; + return colors.normal; + } + + Color getIconColor(MouseState mouseState) { + if (mouseState.isMouseDown) return colors.iconMouseDown; + if (mouseState.isMouseOver) return colors.iconMouseOver; + return colors.iconNormal; + } + + @override + Widget build(BuildContext context) { + if (kIsWeb) { + return Container(); + } else { + // Don't show button on macOS + if (Platform.isMacOS) { + return Container(); + } + } + + return MouseStateBuilder( + builder: (context, mouseState) { + WindowButtonContext buttonContext = WindowButtonContext( + mouseState: mouseState, + context: context, + backgroundColor: getBackgroundColor(mouseState), + iconColor: getIconColor(mouseState)); + + var icon = + (iconBuilder != null) ? iconBuilder!(buttonContext) : Container(); + + var fadeOutColor = + getBackgroundColor(MouseState()..isMouseOver = true).withOpacity(0); + var padding = this.padding ?? const EdgeInsets.all(10); + var animationMs = + mouseState.isMouseOver ? (animate ? 100 : 0) : (animate ? 200 : 0); + Widget iconWithPadding = Padding(padding: padding, child: icon); + iconWithPadding = AnimatedContainer( + curve: Curves.easeOut, + duration: Duration(milliseconds: animationMs), + color: buttonContext.backgroundColor ?? fadeOutColor, + child: iconWithPadding); + var button = + (builder != null) ? builder!(buttonContext, icon) : iconWithPadding; + return SizedBox( + width: 45, + height: 32, + child: button, + ); + }, + onPressed: () { + if (onPressed != null) onPressed!(); + }, + ); + } +} diff --git a/lib/components/tracks_view/track_view.dart b/lib/components/tracks_view/track_view.dart index 36d334cd..2a3f5237 100644 --- a/lib/components/tracks_view/track_view.dart +++ b/lib/components/tracks_view/track_view.dart @@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/tracks_view/sections/header/flexible_header.dart'; import 'package:spotube/components/tracks_view/sections/body/track_view_body.dart'; import 'package:spotube/components/tracks_view/track_view_props.dart'; diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index 7eaf53ae..6a8a3e52 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -13,7 +13,7 @@ import 'package:spotube/modules/player/volume_slider.dart'; import 'package:spotube/components/animated_gradient.dart'; import 'package:spotube/components/dialogs/track_details_dialog.dart'; import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/panels/sliding_up_panel.dart'; import 'package:spotube/extensions/artist_simple.dart'; diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index d38fe778..04389ffc 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -4,7 +4,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/artist/artist_album_list.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index 1e4e3938..d3b0d0cb 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -3,7 +3,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/connect/local_devices.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/provider/connect/clients.dart'; diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index afd17387..eb2c48c5 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -8,7 +8,7 @@ import 'package:spotube/modules/player/volume_slider.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/anchor_button.dart'; import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart index 1f7cb1b5..80548898 100644 --- a/lib/pages/desktop_login/desktop_login.dart +++ b/lib/pages/desktop_login/desktop_login.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/modules/desktop_login/login_form.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart'; diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index d96942e4..d78143e4 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -6,7 +6,7 @@ import 'package:introduction_screen/introduction_screen.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/modules/desktop_login/login_form.dart'; import 'package:spotube/components/links/hyper_link.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/authentication_provider.dart'; diff --git a/lib/pages/getting_started/getting_started.dart b/lib/pages/getting_started/getting_started.dart index 97af2ef4..0159a77f 100644 --- a/lib/pages/getting_started/getting_started.dart +++ b/lib/pages/getting_started/getting_started.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/getting_started/sections/greeting.dart'; import 'package:spotube/pages/getting_started/sections/playback.dart'; diff --git a/lib/pages/home/feed/feed_section.dart b/lib/pages/home/feed/feed_section.dart index 42a22f10..bcfc0b81 100644 --- a/lib/pages/home/feed/feed_section.dart +++ b/lib/pages/home/feed/feed_section.dart @@ -5,7 +5,7 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/views/home_section.dart'; diff --git a/lib/pages/home/genres/genre_playlists.dart b/lib/pages/home/genres/genre_playlists.dart index 4ad37630..58436bcf 100644 --- a/lib/pages/home/genres/genre_playlists.dart +++ b/lib/pages/home/genres/genre_playlists.dart @@ -7,7 +7,7 @@ import 'package:spotify/spotify.dart' hide Offset; import 'package:spotube/collections/fake.dart'; import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify/spotify.dart'; diff --git a/lib/pages/home/genres/genres.dart b/lib/pages/home/genres/genres.dart index cf033bb9..4846d633 100644 --- a/lib/pages/home/genres/genres.dart +++ b/lib/pages/home/genres/genres.dart @@ -6,7 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/gradients.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 27b4cc01..7afd5938 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -12,7 +12,7 @@ import 'package:spotube/modules/home/sections/genres.dart'; import 'package:spotube/modules/home/sections/made_for_user.dart'; import 'package:spotube/modules/home/sections/new_releases.dart'; import 'package:spotube/modules/home/sections/recent.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index 1f4c64e1..da2e4e13 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -5,7 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/dialogs/prompt_dialog.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index 66477761..a0bc1bb7 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart' hide Image; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/modules/library/user_local_tracks.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/library/user_albums.dart'; import 'package:spotube/modules/library/user_artists.dart'; import 'package:spotube/modules/library/user_downloads.dart'; diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart index 648b3c50..830e8a5d 100644 --- a/lib/pages/library/local_folder.dart +++ b/lib/pages/library/local_folder.dart @@ -10,7 +10,7 @@ import 'package:spotube/modules/library/user_local_tracks.dart'; import 'package:spotube/components/expandable_search/expandable_search.dart'; import 'package:spotube/components/fallbacks/not_found.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/sort_tracks_dropdown.dart'; import 'package:spotube/components/track_tile/track_tile.dart'; import 'package:spotube/extensions/artist_simple.dart'; diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 74b7fe26..c73c0b08 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -12,7 +12,7 @@ import 'package:spotube/modules/library/playlist_generate/recommendation_attribu import 'package:spotube/modules/library/playlist_generate/seeds_multi_autocomplete.dart'; import 'package:spotube/modules/library/playlist_generate/simple_track_tile.dart'; import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index a481eac4..90838300 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -7,7 +7,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/library/playlist_generate/simple_track_tile.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/pages/playlist/playlist.dart'; diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 1a4ea7c1..f75c715c 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -7,7 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/themed_button_tab_bar.dart'; import 'package:spotube/extensions/constrains.dart'; diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index bd8fc0a1..603f90d3 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -9,7 +9,7 @@ import 'package:spotube/modules/player/player_controls.dart'; import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/root/sidebar.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_force_update.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart index 06f6848a..e6546960 100644 --- a/lib/pages/profile/profile.dart +++ b/lib/pages/profile/profile.dart @@ -8,7 +8,7 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:url_launcher/url_launcher_string.dart'; diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 828e4aef..4f53f8e6 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -12,7 +12,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_force_update.dart'; diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 2692bfdc..4d093cfe 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -4,7 +4,7 @@ import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/env.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/hyper_link.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_package_info.dart'; diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index c30864fe..b5e10821 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/blacklist_provider.dart'; diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index a790c39d..65e4c82e 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -6,7 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/logger.dart'; diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 2d1c9224..8bce4bcf 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/settings/sections/about.dart'; import 'package:spotube/pages/settings/sections/accounts.dart'; diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index 81eba384..868f068a 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart index f5445326..b3f8c240 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index 5aa06af9..ee141475 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index 9ce84548..ea0a0c10 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index bf1fee93..d31f1dfa 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/playlist_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/stats/stats.dart b/lib/pages/stats/stats.dart index 7dfab844..b2dc03c2 100644 --- a/lib/pages/stats/stats.dart +++ b/lib/pages/stats/stats.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/summary/summary.dart'; import 'package:spotube/modules/stats/top/top.dart'; import 'package:spotube/utils/platform.dart'; diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index 12631816..3df34483 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/provider/history/state.dart'; import 'package:spotube/provider/history/top.dart'; diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index f797c2f2..b5c9e4fa 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -10,7 +10,7 @@ import 'package:spotube/components/heart_button.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/links/link_text.dart'; -import 'package:spotube/components/page_window_title_bar.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/track_tile/track_options.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; From 7816cb8068ce97c2eef1d01a17f0786b2428998e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 9 Jun 2024 09:30:17 +0600 Subject: [PATCH 126/261] refactor: break down heart button hook into a different file --- .../{ => heart_button}/heart_button.dart | 35 +----------- .../heart_button/use_track_toggle_like.dart | 37 ++++++++++++ lib/components/titlebar/mouse_state.dart | 56 ++++++++++--------- lib/components/track_tile/track_options.dart | 2 +- .../sections/header/header_actions.dart | 2 +- lib/modules/player/player_actions.dart | 2 +- lib/pages/track/track.dart | 2 +- 7 files changed, 71 insertions(+), 65 deletions(-) rename lib/components/{ => heart_button}/heart_button.dart (70%) create mode 100644 lib/components/heart_button/use_track_toggle_like.dart diff --git a/lib/components/heart_button.dart b/lib/components/heart_button/heart_button.dart similarity index 70% rename from lib/components/heart_button.dart rename to lib/components/heart_button/heart_button.dart index c296d7a9..8222b8e6 100644 --- a/lib/components/heart_button.dart +++ b/lib/components/heart_button/heart_button.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/heart_button/use_track_toggle_like.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication_provider.dart'; -import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HeartButton extends HookConsumerWidget { @@ -55,38 +54,6 @@ class HeartButton extends HookConsumerWidget { } } -typedef UseTrackToggleLike = ({ - bool isLiked, - Future Function(Track track) toggleTrackLike, -}); - -UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { - final savedTracks = ref.watch(likedTracksProvider); - final savedTracksNotifier = ref.watch(likedTracksProvider.notifier); - - final isLiked = useMemoized( - () => - savedTracks.asData?.value.any((element) => element.id == track.id) ?? - false, - [savedTracks.asData?.value, track.id], - ); - - final scrobblerNotifier = ref.read(scrobblerProvider.notifier); - - return ( - isLiked: isLiked, - toggleTrackLike: (track) async { - await savedTracksNotifier.toggleFavorite(track); - - if (!isLiked) { - await scrobblerNotifier.love(track); - } else { - await scrobblerNotifier.unlove(track); - } - }, - ); -} - class TrackHeartButton extends HookConsumerWidget { final Track track; const TrackHeartButton({ diff --git a/lib/components/heart_button/use_track_toggle_like.dart b/lib/components/heart_button/use_track_toggle_like.dart new file mode 100644 index 00000000..2a886feb --- /dev/null +++ b/lib/components/heart_button/use_track_toggle_like.dart @@ -0,0 +1,37 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +typedef UseTrackToggleLike = ({ + bool isLiked, + Future Function(Track track) toggleTrackLike, +}); + +UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) { + final savedTracks = ref.watch(likedTracksProvider); + final savedTracksNotifier = ref.watch(likedTracksProvider.notifier); + + final isLiked = useMemoized( + () => + savedTracks.asData?.value.any((element) => element.id == track.id) ?? + false, + [savedTracks.asData?.value, track.id], + ); + + final scrobblerNotifier = ref.read(scrobblerProvider.notifier); + + return ( + isLiked: isLiked, + toggleTrackLike: (track) async { + await savedTracksNotifier.toggleFavorite(track); + + if (!isLiked) { + await scrobblerNotifier.love(track); + } else { + await scrobblerNotifier.unlove(track); + } + }, + ); +} diff --git a/lib/components/titlebar/mouse_state.dart b/lib/components/titlebar/mouse_state.dart index 726c6595..9af2a8b0 100644 --- a/lib/components/titlebar/mouse_state.dart +++ b/lib/components/titlebar/mouse_state.dart @@ -33,39 +33,41 @@ class _MouseStateBuilderState extends State { @override Widget build(BuildContext context) { return MouseRegion( - onEnter: (event) { + onEnter: (event) { + setState(() { + _mouseState.isMouseOver = true; + }); + }, + onExit: (event) { + setState(() { + _mouseState.isMouseOver = false; + }); + }, + child: GestureDetector( + onTapDown: (_) { setState(() { - _mouseState.isMouseOver = true; + _mouseState.isMouseDown = true; }); }, - onExit: (event) { + onTapCancel: () { setState(() { + _mouseState.isMouseDown = false; + }); + }, + onTap: () { + setState(() { + _mouseState.isMouseDown = false; _mouseState.isMouseOver = false; }); + _ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) { + if (widget.onPressed != null) { + widget.onPressed!(); + } + }); }, - child: GestureDetector( - onTapDown: (_) { - setState(() { - _mouseState.isMouseDown = true; - }); - }, - onTapCancel: () { - setState(() { - _mouseState.isMouseDown = false; - }); - }, - onTap: () { - setState(() { - _mouseState.isMouseDown = false; - _mouseState.isMouseOver = false; - }); - _ambiguate(WidgetsBinding.instance)!.addPostFrameCallback((_) { - if (widget.onPressed != null) { - widget.onPressed!(); - } - }); - }, - onTapUp: (_) {}, - child: widget.builder(context, _mouseState))); + onTapUp: (_) {}, + child: widget.builder(context, _mouseState), + ), + ); } } diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index de604744..89f6679d 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -12,7 +12,7 @@ import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/dialogs/prompt_dialog.dart'; import 'package:spotube/components/dialogs/track_details_dialog.dart'; -import 'package:spotube/components/heart_button.dart'; +import 'package:spotube/components/heart_button/use_track_toggle_like.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/extensions/constrains.dart'; diff --git a/lib/components/tracks_view/sections/header/header_actions.dart b/lib/components/tracks_view/sections/header/header_actions.dart index a1e959d9..3e0c4cc1 100644 --- a/lib/components/tracks_view/sections/header/header_actions.dart +++ b/lib/components/tracks_view/sections/header/header_actions.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/playlist/playlist_create_dialog.dart'; -import 'package:spotube/components/heart_button.dart'; +import 'package:spotube/components/heart_button/heart_button.dart'; import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart index df366485..41de7388 100644 --- a/lib/modules/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/player/sibling_tracks_sheet.dart'; import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart'; -import 'package:spotube/components/heart_button.dart'; +import 'package:spotube/components/heart_button/heart_button.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index b5c9e4fa..1e9b2067 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -6,7 +6,7 @@ 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/heart_button.dart'; +import 'package:spotube/components/heart_button/heart_button.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/links/link_text.dart'; From d115e570580fb06e630c296167bd5131f7e5863b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 9 Jun 2024 09:56:29 +0600 Subject: [PATCH 127/261] fix: popup menu item opacity --- lib/components/adaptive/adaptive_pop_sheet_list.dart | 5 ++++- lib/themes/theme.dart | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/components/adaptive/adaptive_pop_sheet_list.dart b/lib/components/adaptive/adaptive_pop_sheet_list.dart index 21f56a22..1686801c 100644 --- a/lib/components/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/adaptive/adaptive_pop_sheet_list.dart @@ -226,7 +226,10 @@ class _AdaptivePopSheetListItem extends StatelessWidget { }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), - child: IgnorePointer(child: item), + child: IconTheme.merge( + data: const IconThemeData(opacity: 1), + child: IgnorePointer(child: item), + ), ), ); } diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 8659cf0c..1129b791 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -48,6 +48,9 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), color: scheme.surface, elevation: 4, + labelTextStyle: MaterialStatePropertyAll( + TextStyle(color: scheme.onSurface), + ), ), snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, From f9087b63d5db6837495239a4c188aca06411f997 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 9 Jun 2024 22:52:34 +0600 Subject: [PATCH 128/261] refactor: remove catcher_2 and use custom zoned based error handling --- lib/collections/routes.dart | 3 +- .../configurators/use_endless_playback.dart | 4 +- lib/main.dart | 127 +++++++----------- lib/provider/authentication_provider.dart | 6 +- lib/provider/connect/connect.dart | 9 +- lib/provider/connect/server.dart | 4 +- lib/provider/download_manager_provider.dart | 6 +- .../local_tracks/local_tracks_provider.dart | 8 +- lib/provider/piped_instances_provider.dart | 4 +- .../proxy_playlist/player_listeners.dart | 4 +- .../proxy_playlist/skip_segments.dart | 4 +- lib/provider/scrobbler_provider.dart | 4 +- lib/provider/server/server.dart | 4 +- lib/provider/spotify/lyrics/synced.dart | 2 +- lib/provider/spotify/playlist/generate.dart | 2 +- lib/provider/spotify/spotify.dart | 2 +- lib/services/audio_player/audio_player.dart | 4 +- lib/services/audio_player/custom_player.dart | 4 +- .../download_manager/download_manager.dart | 4 +- lib/services/logger/logger.dart | 53 ++++++++ lib/services/song_link/song_link.dart | 4 +- .../sourced_track/sources/youtube.dart | 4 +- lib/utils/duration.dart | 4 +- 23 files changed, 148 insertions(+), 122 deletions(-) create mode 100644 lib/services/logger/logger.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index e1cc5fb6..b9e06c61 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -1,4 +1,3 @@ -import 'package:catcher_2/catcher_2.dart'; import 'package:flutter/foundation.dart' hide Category; import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; @@ -46,7 +45,7 @@ import 'package:spotube/pages/root/root_app.dart'; import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart'; -final rootNavigatorKey = Catcher2.navigatorKey; +final rootNavigatorKey = GlobalKey(); final shellRouteNavigatorKey = GlobalKey(); final routerProvider = Provider((ref) { return GoRouter( diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart index 98f38165..97eb3f48 100644 --- a/lib/hooks/configurators/use_endless_playback.dart +++ b/lib/hooks/configurators/use_endless_playback.dart @@ -1,4 +1,4 @@ -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -62,7 +62,7 @@ void useEndlessPlayback(WidgetRef ref) { }), ); } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); } } diff --git a/lib/main.dart b/lib/main.dart index 30526bc6..75d2ada5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ -import 'package:catcher_2/catcher_2.dart'; +import 'dart:async'; + import 'package:dart_discord_rpc/dart_discord_rpc.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -19,7 +20,6 @@ import 'package:spotube/hooks/configurators/use_disable_battery_optimizations.da import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; import 'package:spotube/provider/tray_manager/tray_manager.dart'; import 'package:spotube/l10n/l10n.dart'; -import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/connect/clients.dart'; @@ -30,6 +30,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -44,98 +45,73 @@ import 'package:window_manager/window_manager.dart'; Future main(List rawArgs) async { final arguments = await startCLI(rawArgs); + AppLogger.initialize(arguments["verbose"]); - final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + AppLogger.runZoned(() async { + final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); - await registerWindowsScheme("spotify"); + await registerWindowsScheme("spotify"); - tz.initializeTimeZones(); + tz.initializeTimeZones(); - FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); + FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); - MediaKit.ensureInitialized(); + MediaKit.ensureInitialized(); - // force High Refresh Rate on some Android devices (like One Plus) - if (kIsAndroid) { - await FlutterDisplayMode.setHighRefreshRate(); - } + // force High Refresh Rate on some Android devices (like One Plus) + if (kIsAndroid) { + await FlutterDisplayMode.setHighRefreshRate(); + } - if (kIsDesktop) { - await windowManager.setPreventClose(true); - } + if (kIsDesktop) { + await windowManager.setPreventClose(true); + } - await SystemTheme.accentColor.load(); + await SystemTheme.accentColor.load(); - if (!kIsWeb) { - MetadataGod.initialize(); - } + if (!kIsWeb) { + MetadataGod.initialize(); + } - if (kIsWindows || kIsLinux) { - DiscordRPC.initialize(); - } + if (kIsWindows || kIsLinux) { + DiscordRPC.initialize(); + } - await KVStoreService.initialize(); + await KVStoreService.initialize(); - final hiveCacheDir = - kIsWeb ? null : (await getApplicationSupportDirectory()).path; + final hiveCacheDir = + kIsWeb ? null : (await getApplicationSupportDirectory()).path; - Hive.init(hiveCacheDir); + Hive.init(hiveCacheDir); - Hive.registerAdapter(SkipSegmentAdapter()); + Hive.registerAdapter(SkipSegmentAdapter()); - Hive.registerAdapter(SourceMatchAdapter()); - Hive.registerAdapter(SourceTypeAdapter()); + Hive.registerAdapter(SourceMatchAdapter()); + Hive.registerAdapter(SourceTypeAdapter()); - // Cache versioning entities with Adapter - SourceMatch.version = 'v1'; - SkipSegment.version = 'v1'; + // Cache versioning entities with Adapter + SourceMatch.version = 'v1'; + SkipSegment.version = 'v1'; - await Hive.openLazyBox( - SourceMatch.boxName, - path: hiveCacheDir, - ); - await Hive.openLazyBox( - SkipSegment.boxName, - path: hiveCacheDir, - ); - await PersistedStateNotifier.initializeBoxes( - path: hiveCacheDir, - ); + await Hive.openLazyBox( + SourceMatch.boxName, + path: hiveCacheDir, + ); + await Hive.openLazyBox( + SkipSegment.boxName, + path: hiveCacheDir, + ); + await PersistedStateNotifier.initializeBoxes( + path: hiveCacheDir, + ); - if (kIsDesktop) { - await localNotifier.setup(appName: "Spotube"); - await WindowManagerTools.initialize(); - } + if (kIsDesktop) { + await localNotifier.setup(appName: "Spotube"); + await WindowManagerTools.initialize(); + } - Catcher2( - enableLogger: arguments["verbose"], - debugConfig: Catcher2Options( - SilentReportMode(), - [ - ConsoleHandler( - enableDeviceParameters: false, - enableApplicationParameters: false, - ), - if (!kIsWeb) FileHandler(await getLogsPath(), printLogs: false), - ], - ), - releaseConfig: Catcher2Options( - SilentReportMode(), - [ - if (arguments["verbose"] ?? false) ConsoleHandler(), - if (!kIsWeb) - FileHandler( - await getLogsPath(), - printLogs: false, - ), - ], - ), - runAppFunction: () { - runApp( - const ProviderScope(child: Spotube()), - ); - }, - ); + runApp(const ProviderScope(child: Spotube())); + }); } class Spotube extends HookConsumerWidget { @@ -166,6 +142,7 @@ class Spotube extends HookConsumerWidget { useEffect(() { FlutterNativeSplash.remove(); + return () { /// For enabling hot reload for audio player if (!kDebugMode) return; diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication_provider.dart index 95ce6240..52c7f281 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication_provider.dart @@ -73,10 +73,10 @@ class AuthenticationCredentials { ), ); } catch (e) { - if (rootNavigatorKey?.currentContext != null) { + if (rootNavigatorKey.currentContext != null) { showPromptDialog( - context: rootNavigatorKey!.currentContext!, - title: rootNavigatorKey!.currentContext!.l10n + context: rootNavigatorKey.currentContext!, + title: rootNavigatorKey.currentContext!.l10n .error("Authentication Failure"), message: e.toString(), cancelText: null, diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart index 6360c750..feb9fbd2 100644 --- a/lib/provider/connect/connect.dart +++ b/lib/provider/connect/connect.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/connect/connect.dart'; @@ -99,10 +99,7 @@ class ConnectNotifier extends AsyncNotifier { }); }, onError: (error) { - Catcher2.reportCheckedError( - error, - StackTrace.current, - ); + AppLogger.reportError(error, StackTrace.current); }, ); @@ -113,7 +110,7 @@ class ConnectNotifier extends AsyncNotifier { return channel; } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); rethrow; } } diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart index 9c4e6466..aeaaf149 100644 --- a/lib/provider/connect/server.dart +++ b/lib/provider/connect/server.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -215,7 +215,7 @@ final connectServerProvider = FutureProvider((ref) async { ref.read(volumeProvider.notifier).setVolume(event.data); }); } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); + AppLogger.reportError(e, stackTrace); channel.sink.add(WebSocketErrorEvent(e.toString()).toJson()); } }, diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index c964f982..0e80d729 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:io'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; @@ -130,7 +130,7 @@ class DownloadManagerProvider extends ChangeNotifier { return Uint8List.fromList(bytes); } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); + AppLogger.reportError(e, stackTrace); return null; } } @@ -216,7 +216,7 @@ class DownloadManagerProvider extends ChangeNotifier { ); } } catch (e) { - Catcher2.reportCheckedError(e, StackTrace.current); + AppLogger.reportError(e, StackTrace.current); continue; } } diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart index 867774bd..6d2da59c 100644 --- a/lib/provider/local_tracks/local_tracks_provider.dart +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; @@ -56,7 +56,7 @@ final localTracksProvider = try { entities.addAll(Directory(location).listSync(recursive: true)); } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); } } @@ -92,7 +92,7 @@ final localTracksProvider = if (e is FfiException) { return {"file": file}; } - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); return {}; } }, @@ -119,7 +119,7 @@ final localTracksProvider = } return tracks; } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); return {}; } }); diff --git a/lib/provider/piped_instances_provider.dart b/lib/provider/piped_instances_provider.dart index d571f730..3c5d5f04 100644 --- a/lib/provider/piped_instances_provider.dart +++ b/lib/provider/piped_instances_provider.dart @@ -1,4 +1,4 @@ -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotube/services/sourced_track/sources/piped.dart'; @@ -10,7 +10,7 @@ final pipedInstancesFutureProvider = FutureProvider>( return await pipedClient.instanceList(); } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); return []; } }, diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/proxy_playlist/player_listeners.dart index 3c36491c..2c1423a5 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/proxy_playlist/player_listeners.dart @@ -2,7 +2,7 @@ import 'dart:async'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; @@ -86,7 +86,7 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { history.addTrack(playlist.activeTrack!); lastScrobbled = uid; } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); } }); } diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart index 7f3d1e9a..12d066ac 100644 --- a/lib/provider/proxy_playlist/skip_segments.dart +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -1,4 +1,4 @@ -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:dio/dio.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/models/skip_segment.dart'; @@ -71,7 +71,7 @@ Future> getAndCacheSkipSegments(String id) async { return List.castFrom(segments); } catch (e, stack) { await SkipSegment.box.put(id, []); - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); return List.castFrom([]); } } diff --git a/lib/provider/scrobbler_provider.dart b/lib/provider/scrobbler_provider.dart index 9ad2a58b..ab111ea4 100644 --- a/lib/provider/scrobbler_provider.dart +++ b/lib/provider/scrobbler_provider.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:scrobblenaut/scrobblenaut.dart'; import 'package:spotify/spotify.dart'; @@ -52,7 +52,7 @@ class ScrobblerNotifier extends PersistedStateNotifier { trackNumber: track.trackNumber, ); } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); + AppLogger.reportError(e, stackTrace); } }); } diff --git a/lib/provider/server/server.dart b/lib/provider/server/server.dart index 009cc534..b6a7dfe9 100644 --- a/lib/provider/server/server.dart +++ b/lib/provider/server/server.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'dart:math'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:dio/dio.dart' hide Response; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -108,7 +108,7 @@ class PlaybackServer { headers: res.headers.map, ); } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); return Response.internalServerError(); } } diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index 04a2ddca..ef83a1a1 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -145,7 +145,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier return lyrics; } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); + AppLogger.reportError(e, stackTrace); rethrow; } } diff --git a/lib/provider/spotify/playlist/generate.dart b/lib/provider/spotify/playlist/generate.dart index 15447b54..2e1196dd 100644 --- a/lib/provider/spotify/playlist/generate.dart +++ b/lib/provider/spotify/playlist/generate.dart @@ -24,7 +24,7 @@ final generatePlaylistProvider = FutureProvider.autoDispose ?.cast(), ) .catchError((e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); + AppLogger.reportError(e, stackTrace); return Recommendations(); }); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index ac83ba72..e592e93b 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -2,7 +2,7 @@ library spotify; import 'dart:async'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 8d3e0bfb..8b391a07 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:flutter/foundation.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/local_track.dart'; @@ -57,7 +57,7 @@ abstract class AudioPlayerInterface { ), ) { _mkPlayer.stream.error.listen((event) { - Catcher2.reportCheckedError(event, StackTrace.current); + AppLogger.reportError(event, StackTrace.current); }); } diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart index e32a0d14..f0dc8f13 100644 --- a/lib/services/audio_player/custom_player.dart +++ b/lib/services/audio_player/custom_player.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:media_kit/media_kit.dart'; import 'package:flutter_broadcasts/flutter_broadcasts.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -48,7 +48,7 @@ class CustomPlayer extends Player { } }), stream.error.listen((event) { - Catcher2.reportCheckedError('[MediaKitError] \n$event', null); + AppLogger.reportError('[MediaKitError] \n$event', StackTrace.current); }), ]; PackageInfo.fromPlatform().then((packageInfo) { diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart index 54d35b02..afbee88c 100644 --- a/lib/services/download_manager/download_manager.dart +++ b/lib/services/download_manager/download_manager.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; @@ -148,7 +148,7 @@ class DownloadManager { } } } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); + AppLogger.reportError(e, stackTrace); var task = getDownload(url)!; if (task.status.value != DownloadStatus.canceled && diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart new file mode 100644 index 00000000..1a9f3771 --- /dev/null +++ b/lib/services/logger/logger.dart @@ -0,0 +1,53 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter/foundation.dart'; +import 'package:logger/logger.dart'; + +class AppLogger { + static late final Logger log; + + static initialize(bool verbose) { + log = Logger( + level: kDebugMode || (verbose && kReleaseMode) ? Level.all : Level.info, + ); + } + + static R? runZoned(R Function() body) { + FlutterError.onError = (details) { + reportError(details.exception, details.stack ?? StackTrace.current); + }; + + PlatformDispatcher.instance.onError = (error, stackTrace) { + reportError(error, stackTrace); + return true; + }; + + if (!kIsWeb) { + Isolate.current.addErrorListener( + RawReceivePort((pair) async { + final isolateError = pair as List; + reportError( + isolateError.first.toString(), + isolateError.last, + ); + }).sendPort, + ); + } + + return runZonedGuarded( + body, + (error, stackTrace) { + reportError(error, stackTrace); + }, + ); + } + + static void reportError( + dynamic error, [ + StackTrace? stackTrace, + message = "", + ]) { + log.e(message, error: error, stackTrace: stackTrace); + } +} diff --git a/lib/services/song_link/song_link.dart b/lib/services/song_link/song_link.dart index b02f60cb..e3cffa52 100644 --- a/lib/services/song_link/song_link.dart +++ b/lib/services/song_link/song_link.dart @@ -2,7 +2,7 @@ library song_link; import 'dart:convert'; -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:dio/dio.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:html/parser.dart'; @@ -47,7 +47,7 @@ abstract class SongLinkService { return songLinks?.map((link) => SongLink.fromJson(link)).toList() ?? []; } catch (e, stackTrace) { - Catcher2.reportCheckedError(e, stackTrace); + AppLogger.reportError(e, stackTrace); return []; } } diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index af61a882..b144d701 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -1,9 +1,9 @@ -import 'package:catcher_2/core/catcher_2.dart'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/source_match.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/song_link/song_link.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; @@ -236,7 +236,7 @@ class YoutubeSourcedTrack extends SourcedTrack { ]; } on VideoUnplayableException catch (e, stack) { // Ignore this error and continue with the search - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); } } diff --git a/lib/utils/duration.dart b/lib/utils/duration.dart index a2bb4d16..1869cea1 100644 --- a/lib/utils/duration.dart +++ b/lib/utils/duration.dart @@ -1,4 +1,4 @@ -import 'package:catcher_2/catcher_2.dart'; +import 'package:spotube/services/logger/logger.dart'; /// Parses duration string formatted by Duration.toString() to [Duration]. /// The string should be of form hours:minutes:seconds.microseconds @@ -51,7 +51,7 @@ Duration? tryParseDuration(String input) { try { return parseDuration(input); } catch (e, stack) { - Catcher2.reportCheckedError(e, stack); + AppLogger.reportError(e, stack); return null; } } From de61d90938db580a177ffbd6066dfb023a1a3164 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 9 Jun 2024 22:58:14 +0600 Subject: [PATCH 129/261] refactor: add back exceptions to file support --- lib/services/logger/logger.dart | 36 +++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart index 1a9f3771..82708478 100644 --- a/lib/services/logger/logger.dart +++ b/lib/services/logger/logger.dart @@ -1,16 +1,23 @@ import 'dart:async'; +import 'dart:io'; import 'dart:isolate'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotube/utils/platform.dart'; class AppLogger { static late final Logger log; + static late final File logFile; static initialize(bool verbose) { log = Logger( level: kDebugMode || (verbose && kReleaseMode) ? Level.all : Level.info, ); + + getLogsPath().then((value) => logFile = value); } static R? runZoned(R Function() body) { @@ -43,11 +50,36 @@ class AppLogger { ); } - static void reportError( + static Future getLogsPath() async { + String dir = (await getApplicationDocumentsDirectory()).path; + if (kIsAndroid) { + dir = (await getExternalStorageDirectory())?.path ?? ""; + } + + if (kIsMacOS) { + dir = join((await getLibraryDirectory()).path, "Logs"); + } + final file = File(join(dir, ".spotube_logs")); + if (!await file.exists()) { + await file.create(recursive: true); + } + return file; + } + + static Future reportError( dynamic error, [ StackTrace? stackTrace, message = "", - ]) { + ]) async { log.e(message, error: error, stackTrace: stackTrace); + + if (kReleaseMode) { + await logFile.writeAsString( + "[${DateTime.now()}]---------------------\n" + "$error\n$stackTrace\n" + "----------------------------------------\n", + mode: FileMode.writeOnlyAppend, + ); + } } } From 2822d5dbfddd7845d0eb4d863d4c0256876649dc Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 9 Jun 2024 23:05:19 +0600 Subject: [PATCH 130/261] chore: fix widget binding errors --- lib/services/logger/logger.dart | 53 ++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart index 82708478..6ba76ea1 100644 --- a/lib/services/logger/logger.dart +++ b/lib/services/logger/logger.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'dart:isolate'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:logger/logger.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; @@ -16,34 +17,38 @@ class AppLogger { log = Logger( level: kDebugMode || (verbose && kReleaseMode) ? Level.all : Level.info, ); - - getLogsPath().then((value) => logFile = value); } static R? runZoned(R Function() body) { - FlutterError.onError = (details) { - reportError(details.exception, details.stack ?? StackTrace.current); - }; - - PlatformDispatcher.instance.onError = (error, stackTrace) { - reportError(error, stackTrace); - return true; - }; - - if (!kIsWeb) { - Isolate.current.addErrorListener( - RawReceivePort((pair) async { - final isolateError = pair as List; - reportError( - isolateError.first.toString(), - isolateError.last, - ); - }).sendPort, - ); - } - return runZonedGuarded( - body, + () { + WidgetsFlutterBinding.ensureInitialized(); + + FlutterError.onError = (details) { + reportError(details.exception, details.stack ?? StackTrace.current); + }; + + PlatformDispatcher.instance.onError = (error, stackTrace) { + reportError(error, stackTrace); + return true; + }; + + if (!kIsWeb) { + Isolate.current.addErrorListener( + RawReceivePort((pair) async { + final isolateError = pair as List; + reportError( + isolateError.first.toString(), + isolateError.last, + ); + }).sendPort, + ); + } + + getLogsPath().then((value) => logFile = value); + + return body(); + }, (error, stackTrace) { reportError(error, stackTrace); }, From 9ce911a8abe0e442ab226194f0b138662a85d7af Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 10 Jun 2024 21:47:53 +0600 Subject: [PATCH 131/261] cd: upgrade to flutter 3.22.2 --- .fvm/fvm_config.json | 2 +- .github/workflows/pr-lint.yml | 2 +- .github/workflows/spotube-release-binary.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 0b54542f..df8efa0e 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.19.6", + "flutterSdkVersion": "3.22.1", "flavors": {} } \ No newline at end of file diff --git a/.github/workflows/pr-lint.yml b/.github/workflows/pr-lint.yml index 64cc8adc..db158029 100644 --- a/.github/workflows/pr-lint.yml +++ b/.github/workflows/pr-lint.yml @@ -4,7 +4,7 @@ on: pull_request: env: - FLUTTER_VERSION: '3.19.6' + FLUTTER_VERSION: 3.22.2 jobs: lint: diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index e99aebab..02b47f18 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -20,7 +20,7 @@ on: description: Dry run without uploading to release env: - FLUTTER_VERSION: 3.19.6 + FLUTTER_VERSION: 3.22.2 permissions: contents: write From 6067314c5a4f296301e44315f3cc371008129a6e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 10 Jun 2024 22:27:33 +0600 Subject: [PATCH 132/261] cd: revert to flutter 3.22.1 --- .github/workflows/spotube-release-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 02b47f18..5b74c9b5 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -20,7 +20,7 @@ on: description: Dry run without uploading to release env: - FLUTTER_VERSION: 3.22.2 + FLUTTER_VERSION: 3.22.1 permissions: contents: write From 4f2175987d9619b67a4456299155abcd0301da13 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 11 Jun 2024 23:02:23 +0600 Subject: [PATCH 133/261] refactor: remove uncessary methods --- .../spotify_endpoints.dart | 52 ------------------- pubspec.lock | 28 +++++----- 2 files changed, 14 insertions(+), 66 deletions(-) diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index 0c7daeb2..3b358366 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -109,58 +109,6 @@ class CustomSpotifyEndpoints { } } - void _addList( - Map parameters, String key, Iterable paramList) { - if (paramList.isNotEmpty) { - parameters[key] = paramList.join(','); - } - } - - void _addTunableTrackMap( - Map parameters, Map? tunableTrackMap) { - if (tunableTrackMap != null) { - parameters.addAll(tunableTrackMap.map((k, v) => - MapEntry(k, v is int ? v.toString() : v.toStringAsFixed(2)))); - } - } - - Future> getRecommendations({ - Iterable? seedArtists, - Iterable? seedGenres, - Iterable? seedTracks, - int limit = 20, - Market? market, - Map? max, - Map? min, - Map? target, - }) async { - assert(limit >= 1 && limit <= 100, 'limit should be 1 <= limit <= 100'); - final seedsNum = (seedArtists?.length ?? 0) + - (seedGenres?.length ?? 0) + - (seedTracks?.length ?? 0); - assert( - seedsNum >= 1 && seedsNum <= 5, - 'Up to 5 seed values may be provided in any combination of seed_artists,' - ' seed_tracks and seed_genres.'); - final parameters = {'limit': limit.toString()}; - final _ = { - 'seed_artists': seedArtists, - 'seed_genres': seedGenres, - 'seed_tracks': seedTracks - }.forEach((key, list) => _addList(parameters, key, list!)); - if (market != null) parameters['market'] = market.name; - for (var map in [min, max, target]) { - _addTunableTrackMap(parameters, map); - } - final pathQuery = - "$_baseUrl/recommendations?${parameters.entries.map((e) => '${e.key}=${e.value}').join('&')}"; - final res = await _client.getUri(Uri.parse(pathQuery)); - final result = res.data; - return List.castFrom( - result["tracks"].map((track) => Track.fromJson(track)).toList(), - ); - } - Future getFriendActivity() async { final res = await _client.getUri( Uri.parse("https://guc-spclient.spotify.com/presence-view/v1/buddylist"), diff --git a/pubspec.lock b/pubspec.lock index da410958..c11577f2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1242,10 +1242,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.19.0" introduction_screen: dependency: "direct main" description: @@ -1298,26 +1298,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -1458,10 +1458,10 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" metadata_god: dependency: "direct main" description: @@ -2146,10 +2146,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" time: dependency: transitive description: @@ -2354,10 +2354,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" watcher: dependency: transitive description: From cb8d24ff311740b110ed677005910d71730d5027 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 11 Jun 2024 23:58:50 +0600 Subject: [PATCH 134/261] chore: remove uncessary dependencies --- pubspec.lock | 62 ++++++---------------------------------------------- pubspec.yaml | 13 +++++------ 2 files changed, 13 insertions(+), 62 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index c11577f2..c1866e7d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -213,10 +213,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" + sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" url: "https://pub.dev" source: hosted - version: "2.4.9" + version: "2.4.11" build_runner_core: dependency: transitive description: @@ -273,14 +273,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - catcher_2: - dependency: "direct main" - description: - name: catcher_2 - sha256: "3c8f6cedc8c5eab61192830096d4f303900a5d0bddbf96a07ff9f7a8d5ff8fcd" - url: "https://pub.dev" - source: hosted - version: "1.2.4" change_case: dependency: transitive description: @@ -370,7 +362,7 @@ packages: source: hosted version: "0.3.4+1" crypto: - dependency: "direct main" + dependency: "direct dev" description: name: crypto sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab @@ -850,14 +842,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_mailer: - dependency: transitive - description: - name: flutter_mailer - sha256: "4fffaa35e911ff5ec2e5a4ebbca62c372e99a154eb3bb2c0bf79f09adf6ecf4c" - url: "https://pub.dev" - source: hosted - version: "2.1.2" flutter_native_splash: dependency: "direct main" description: @@ -964,14 +948,6 @@ packages: description: flutter source: sdk version: "0.0.0" - fluttertoast: - dependency: transitive - description: - name: fluttertoast - sha256: "81b68579e23fcbcada2db3d50302813d2371664afe6165bc78148050ab94bf66" - url: "https://pub.dev" - source: hosted - version: "8.2.5" form_validator: dependency: "direct main" description: @@ -1358,14 +1334,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - mailer: - dependency: transitive - description: - name: mailer - sha256: d25d89555c1031abacb448f07b801d7c01b4c21d4558e944b12b64394c84a3cb - url: "https://pub.dev" - source: hosted - version: "6.1.0" matcher: dependency: transitive description: @@ -1711,7 +1679,7 @@ packages: source: hosted version: "0.14.2" pub_api_client: - dependency: "direct main" + dependency: "direct dev" description: name: pub_api_client sha256: cc3d2c93df3823553de6a3e7d3ac09a3f43f8c271af4f43c2795266090ac9625 @@ -1735,7 +1703,7 @@ packages: source: hosted version: "2.3.0" pubspec_parse: - dependency: "direct main" + dependency: "direct dev" description: name: pubspec_parse sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 @@ -1767,7 +1735,7 @@ packages: source: hosted version: "4.1.0" riverpod: - dependency: transitive + dependency: "direct main" description: name: riverpod sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d @@ -1831,14 +1799,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.1" - sentry: - dependency: transitive - description: - name: sentry - sha256: "19a267774906ca3a3c4677fc7e9582ea9da79ae9a28f84bbe4885dac2c269b70" - url: "https://pub.dev" - source: hosted - version: "7.20.0" shared_preferences: dependency: "direct main" description: @@ -1951,14 +1911,6 @@ packages: url: "https://pub.dev" source: hosted version: "10.1.3" - skeleton_text: - dependency: "direct main" - description: - name: skeleton_text - sha256: bacd536bf0664efe1cae53bcbd78c3d4040a120f300f69dc85d83f358471cc6c - url: "https://pub.dev" - source: hosted - version: "3.0.1" skeletonizer: dependency: "direct main" description: @@ -2455,5 +2407,5 @@ packages: source: hosted version: "2.2.1" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.4.0 <4.0.0" flutter: ">=3.19.2" diff --git a/pubspec.yaml b/pubspec.yaml index ffa8511f..bd1717c8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,6 @@ dependencies: auto_size_text: ^3.0.0 buttons_tabbar: ^1.3.8 cached_network_image: ^3.3.1 - catcher_2: ^1.2.4 collection: ^1.15.0 curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 @@ -65,7 +64,7 @@ dependencies: mime: ^1.0.2 package_info_plus: ^6.0.0 palette_generator: ^0.3.3 - path: ^1.8.0 + path: ^1.9.0 path_provider: ^2.1.3 permission_handler: ^11.3.1 piped_client: ^0.1.1 @@ -77,7 +76,6 @@ dependencies: scroll_to_index: ^3.0.1 sidebarx: ^0.17.1 shared_preferences: ^2.2.3 - skeleton_text: ^3.0.1 smtc_windows: ^0.1.3 stroke_text: ^0.0.2 system_theme: ^2.1.0 @@ -118,16 +116,15 @@ dependencies: shelf_web_socket: ^1.0.4 web_socket_channel: ^2.4.5 lrc: ^1.0.2 - pub_api_client: ^2.4.0 - pubspec_parse: ^1.2.2 timezone: ^0.9.2 - crypto: ^3.0.3 local_notifier: ^0.1.6 tray_manager: ^0.2.2 http: ^1.2.1 + riverpod: ^2.5.1 dev_dependencies: - build_runner: ^2.4.9 + build_runner: ^2.4.11 + crypto: ^3.0.3 envied_generator: ^0.5.4+1 flutter_gen_runner: ^5.4.0 flutter_launcher_icons: ^0.13.1 @@ -142,6 +139,8 @@ dev_dependencies: custom_lint: ^0.6.4 riverpod_lint: ^2.3.10 process_run: ^0.14.2 + pubspec_parse: ^1.2.2 + pub_api_client: ^2.4.0 xml: ^6.5.0 io: ^1.0.4 From 064d92d35d5d2752ac0625d5204be2d098acdc51 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 12 Jun 2024 20:46:49 +0600 Subject: [PATCH 135/261] refactor: merge connect and playback server into one server --- lib/main.dart | 8 +- lib/pages/root/root_app.dart | 5 +- lib/provider/connect/server.dart | 270 -------------------- lib/provider/server/bonsoir.dart | 41 +++ lib/provider/server/pipeline.dart | 11 + lib/provider/server/router.dart | 20 ++ lib/provider/server/routes/connect.dart | 205 +++++++++++++++ lib/provider/server/routes/playback.dart | 73 ++++++ lib/provider/server/server.dart | 130 ++-------- lib/services/audio_player/audio_player.dart | 4 +- 10 files changed, 382 insertions(+), 385 deletions(-) delete mode 100644 lib/provider/connect/server.dart create mode 100644 lib/provider/server/bonsoir.dart create mode 100644 lib/provider/server/pipeline.dart create mode 100644 lib/provider/server/router.dart create mode 100644 lib/provider/server/routes/connect.dart create mode 100644 lib/provider/server/routes/playback.dart diff --git a/lib/main.dart b/lib/main.dart index 75d2ada5..1f5e5909 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,14 +18,14 @@ 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/provider/server/bonsoir.dart'; +import 'package:spotube/provider/server/server.dart'; import 'package:spotube/provider/tray_manager/tray_manager.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/server/server.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; @@ -130,8 +130,8 @@ class Spotube extends HookConsumerWidget { ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); final router = ref.watch(routerProvider); - ref.listen(playbackServerProvider, (_, __) {}); - ref.listen(connectServerProvider, (_, __) {}); + ref.listen(serverProvider, (_, __) {}); + ref.listen(bonsoirProvider, (_, __) {}); ref.listen(connectClientsProvider, (_, __) {}); ref.listen(trayManagerProvider, (_, __) {}); diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 6f14a10b..93a84f0a 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -15,9 +15,9 @@ import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/server/routes/connect.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; @@ -36,6 +36,7 @@ class RootApp extends HookConsumerWidget { final downloader = ref.watch(downloadManagerProvider); final scaffoldMessenger = ScaffoldMessenger.of(context); final theme = Theme.of(context); + final connectRoutes = ref.watch(serverConnectRoutesProvider); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { @@ -90,7 +91,7 @@ class RootApp extends HookConsumerWidget { ); } }), - connectClientStream.listen((clientOrigin) { + connectRoutes.connectClientStream.listen((clientOrigin) { scaffoldMessenger.showSnackBar( SnackBar( backgroundColor: Colors.yellow[600], diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart deleted file mode 100644 index aeaaf149..00000000 --- a/lib/provider/connect/server.dart +++ /dev/null @@ -1,270 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; - -import 'package:spotube/services/logger/logger.dart'; -import 'package:shelf/shelf.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shelf_router/shelf_router.dart'; -import 'package:shelf_web_socket/shelf_web_socket.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:bonsoir/bonsoir.dart'; -import 'package:spotube/services/device_info/device_info.dart'; -import 'package:spotube/utils/primitive_utils.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; -import 'package:spotube/provider/volume_provider.dart'; - -final logger = getLogger('ConnectServer'); -final _connectClientStreamController = StreamController.broadcast(); - -Stream get connectClientStream => _connectClientStreamController.stream; - -final connectServerProvider = FutureProvider((ref) async { - final enabled = - ref.watch(userPreferencesProvider.select((s) => s.enableConnect)); - final resolvedService = await ref - .watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); - final playbackNotifier = ref.read(proxyPlaylistProvider.notifier); - final historyNotifier = ref.read(playbackHistoryProvider.notifier); - - if (!enabled || resolvedService != null) { - return null; - } - - final app = Router(); - - app.get( - "/ping", - (Request req) { - return Response.ok("pong"); - }, - ); - - final subscriptions = []; - - FutureOr websocket(Request req) => webSocketHandler( - (WebSocketChannel channel, String? protocol) async { - final context = - (req.context["shelf.io.connection_info"] as HttpConnectionInfo?); - final origin = - "${context?.remoteAddress.host}:${context?.remotePort}"; - _connectClientStreamController.add(origin); - - ref.listen( - proxyPlaylistProvider, - (previous, next) { - channel.sink.add( - WebSocketQueueEvent(next).toJson(), - ); - }, - fireImmediately: true, - ); - - // because audioPlayer events doesn't fireImmediately - channel.sink.add( - WebSocketPlayingEvent(audioPlayer.isPlaying).toJson(), - ); - channel.sink.add( - WebSocketPositionEvent(await audioPlayer.position ?? Duration.zero) - .toJson(), - ); - channel.sink.add( - WebSocketDurationEvent(await audioPlayer.duration ?? Duration.zero) - .toJson(), - ); - channel.sink.add( - WebSocketShuffleEvent(audioPlayer.isShuffled).toJson(), - ); - channel.sink.add( - WebSocketLoopEvent(audioPlayer.loopMode).toJson(), - ); - channel.sink.add( - WebSocketVolumeEvent(audioPlayer.volume).toJson(), - ); - - subscriptions.addAll([ - audioPlayer.positionStream.listen( - (position) { - channel.sink.add( - WebSocketPositionEvent(position).toJson(), - ); - }, - ), - audioPlayer.playingStream.listen( - (playing) { - channel.sink.add( - WebSocketPlayingEvent(playing).toJson(), - ); - }, - ), - audioPlayer.durationStream.listen( - (duration) { - channel.sink.add( - WebSocketDurationEvent(duration).toJson(), - ); - }, - ), - audioPlayer.shuffledStream.listen( - (shuffled) { - channel.sink.add( - WebSocketShuffleEvent(shuffled).toJson(), - ); - }, - ), - audioPlayer.loopModeStream.listen( - (loopMode) { - channel.sink.add( - WebSocketLoopEvent(loopMode).toJson(), - ); - }, - ), - audioPlayer.volumeStream.listen( - (volume) { - channel.sink.add( - WebSocketVolumeEvent(volume).toJson(), - ); - }, - ), - channel.stream.listen( - (message) { - try { - final event = WebSocketEvent.fromJson( - jsonDecode(message), - (data) => data, - ); - - event.onLoad((event) async { - await playbackNotifier.load( - event.data.tracks, - autoPlay: true, - initialIndex: event.data.initialIndex ?? 0, - ); - - if (event.data.collectionId == null) return; - playbackNotifier.addCollection(event.data.collectionId!); - if (event.data.collection is AlbumSimple) { - historyNotifier - .addAlbums([event.data.collection as AlbumSimple]); - } else { - historyNotifier.addPlaylists( - [event.data.collection as PlaylistSimple]); - } - }); - - event.onPause((event) async { - await audioPlayer.pause(); - }); - - event.onResume((event) async { - await audioPlayer.resume(); - }); - - event.onStop((event) async { - await audioPlayer.stop(); - }); - - event.onNext((event) async { - await playbackNotifier.next(); - }); - - event.onPrevious((event) async { - await playbackNotifier.previous(); - }); - - event.onJump((event) async { - await playbackNotifier.jumpTo(event.data); - }); - - event.onSeek((event) async { - await audioPlayer.seek(event.data); - }); - - event.onShuffle((event) async { - await audioPlayer.setShuffle(event.data); - }); - - event.onLoop((event) async { - await audioPlayer.setLoopMode(event.data); - }); - - event.onAddTrack((event) async { - await playbackNotifier.addTrack(event.data); - }); - - event.onRemoveTrack((event) async { - await playbackNotifier.removeTrack(event.data); - }); - - event.onReorder((event) async { - await playbackNotifier.moveTrack( - event.data.oldIndex, - event.data.newIndex, - ); - }); - - event.onVolume((event) async { - ref.read(volumeProvider.notifier).setVolume(event.data); - }); - } catch (e, stackTrace) { - AppLogger.reportError(e, stackTrace); - channel.sink.add(WebSocketErrorEvent(e.toString()).toJson()); - } - }, - onDone: () { - logger.i('Connection closed'); - }, - ), - ]); - }, - )(req); - - final port = Random().nextInt(17000) + 3000; - - final server = await serve( - (request) { - if (request.url.path.startsWith('ws')) { - return websocket(request); - } - return app(request); - }, - InternetAddress.anyIPv4, - port, - ); - - logger.i('Server running on http://${server.address.host}:${server.port}'); - - final service = BonsoirService( - name: await DeviceInfoService.instance.computerName(), - type: '_spotube._tcp', - port: port, - attributes: { - "id": PrimitiveUtils.uuid.v4(), - "deviceId": await DeviceInfoService.instance.deviceId(), - }, - ); - - final broadcast = BonsoirBroadcast(service: service); - - await broadcast.ready; - await broadcast.start(); - - ref.onDispose(() async { - logger.i('Stopping server'); - for (final subscription in subscriptions) { - await subscription.cancel(); - } - await broadcast.stop(); - await server.close(); - }); - - return app; -}); diff --git a/lib/provider/server/bonsoir.dart b/lib/provider/server/bonsoir.dart new file mode 100644 index 00000000..fcc40e54 --- /dev/null +++ b/lib/provider/server/bonsoir.dart @@ -0,0 +1,41 @@ +import 'package:bonsoir/bonsoir.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/server/server.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/device_info/device_info.dart'; +import 'package:spotube/utils/primitive_utils.dart'; + +final bonsoirProvider = FutureProvider((ref) async { + final enabled = ref.watch( + userPreferencesProvider.select((s) => s.enableConnect), + ); + final resolvedService = await ref.watch( + connectClientsProvider.selectAsync((s) => s.resolvedService), + ); + + if (!enabled || resolvedService != null) { + return null; + } + + final (server: _, :port) = await ref.watch(serverProvider.future); + + final service = BonsoirService( + name: await DeviceInfoService.instance.computerName(), + type: '_spotube._tcp', + port: port, + attributes: { + "id": PrimitiveUtils.uuid.v4(), + "deviceId": await DeviceInfoService.instance.deviceId(), + }, + ); + + final broadcast = BonsoirBroadcast(service: service); + + await broadcast.ready; + await broadcast.start(); + + ref.onDispose(() async { + await broadcast.stop(); + }); +}); diff --git a/lib/provider/server/pipeline.dart b/lib/provider/server/pipeline.dart new file mode 100644 index 00000000..8f97ce89 --- /dev/null +++ b/lib/provider/server/pipeline.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelf/shelf.dart'; + +final pipelineProvider = Provider((ref) { + const pipeline = Pipeline(); + if (kDebugMode) { + pipeline.addMiddleware(logRequests()); + } + return pipeline; +}); diff --git a/lib/provider/server/router.dart b/lib/provider/server/router.dart new file mode 100644 index 00000000..e2a579cc --- /dev/null +++ b/lib/provider/server/router.dart @@ -0,0 +1,20 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:spotube/provider/server/routes/connect.dart'; +import 'package:spotube/provider/server/routes/playback.dart'; + +final serverRouterProvider = Provider((ref) { + final playbackRoutes = ref.watch(serverPlaybackRoutesProvider); + final connectRoutes = ref.watch(serverConnectRoutesProvider); + + final router = Router(); + + router.get("/ping", (Request request) => Response.ok("pong")); + + router.get("/stream/", playbackRoutes.getStreamTrackId); + + router.all("/ws", connectRoutes.websocket); + + return router; +}); diff --git a/lib/provider/server/routes/connect.dart b/lib/provider/server/routes/connect.dart new file mode 100644 index 00000000..eee3365e --- /dev/null +++ b/lib/provider/server/routes/connect.dart @@ -0,0 +1,205 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/models/logger.dart'; +import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/volume_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +extension _WebsocketSinkExts on WebSocketSink { + void addEvent(WebSocketEvent event) { + add(event.toJson()); + } +} + +class ServerConnectRoutes { + final Ref ref; + final StreamController _connectClientStreamController; + final List subscriptions; + final SpotubeLogger logger; + ServerConnectRoutes(this.ref) + : _connectClientStreamController = StreamController.broadcast(), + subscriptions = [], + logger = getLogger('ConnectServer') { + ref.onDispose(() { + _connectClientStreamController.close(); + for (final subscription in subscriptions) { + subscription.cancel(); + } + }); + } + + ProxyPlaylistNotifier get playbackNotifier => + ref.read(proxyPlaylistProvider.notifier); + PlaybackHistoryNotifier get historyNotifier => + ref.read(playbackHistoryProvider.notifier); + Stream get connectClientStream => + _connectClientStreamController.stream; + + FutureOr websocket(Request req) { + return webSocketHandler( + ( + WebSocketChannel channel, + String? protocol, + ) async { + final context = + (req.context["shelf.io.connection_info"] as HttpConnectionInfo?); + final origin = "${context?.remoteAddress.host}:${context?.remotePort}"; + _connectClientStreamController.add(origin); + + ref.listen( + proxyPlaylistProvider, + (previous, next) { + channel.sink.addEvent(WebSocketQueueEvent(next)); + }, + fireImmediately: true, + ); + + // because audioPlayer events doesn't fireImmediately + channel.sink.addEvent(WebSocketPlayingEvent(audioPlayer.isPlaying)); + channel.sink.addEvent( + WebSocketPositionEvent(await audioPlayer.position ?? Duration.zero), + ); + channel.sink.addEvent( + WebSocketDurationEvent(await audioPlayer.duration ?? Duration.zero), + ); + channel.sink.addEvent(WebSocketShuffleEvent(audioPlayer.isShuffled)); + channel.sink.addEvent(WebSocketLoopEvent(audioPlayer.loopMode)); + channel.sink.addEvent(WebSocketVolumeEvent(audioPlayer.volume)); + + subscriptions.addAll([ + audioPlayer.positionStream.listen( + (position) { + channel.sink.addEvent(WebSocketPositionEvent(position)); + }, + ), + audioPlayer.playingStream.listen( + (playing) { + channel.sink.addEvent(WebSocketPlayingEvent(playing)); + }, + ), + audioPlayer.durationStream.listen( + (duration) { + channel.sink.addEvent(WebSocketDurationEvent(duration)); + }, + ), + audioPlayer.shuffledStream.listen( + (shuffled) { + channel.sink.addEvent(WebSocketShuffleEvent(shuffled)); + }, + ), + audioPlayer.loopModeStream.listen( + (loopMode) { + channel.sink.addEvent(WebSocketLoopEvent(loopMode)); + }, + ), + audioPlayer.volumeStream.listen( + (volume) { + channel.sink.addEvent(WebSocketVolumeEvent(volume)); + }, + ), + channel.stream.listen( + (message) { + try { + final event = WebSocketEvent.fromJson( + jsonDecode(message), + (data) => data, + ); + + event.onLoad((event) async { + await playbackNotifier.load( + event.data.tracks, + autoPlay: true, + initialIndex: event.data.initialIndex ?? 0, + ); + + if (event.data.collectionId == null) return; + playbackNotifier.addCollection(event.data.collectionId!); + if (event.data.collection is AlbumSimple) { + historyNotifier + .addAlbums([event.data.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists( + [event.data.collection as PlaylistSimple]); + } + }); + + event.onPause((event) async { + await audioPlayer.pause(); + }); + + event.onResume((event) async { + await audioPlayer.resume(); + }); + + event.onStop((event) async { + await audioPlayer.stop(); + }); + + event.onNext((event) async { + await playbackNotifier.next(); + }); + + event.onPrevious((event) async { + await playbackNotifier.previous(); + }); + + event.onJump((event) async { + await playbackNotifier.jumpTo(event.data); + }); + + event.onSeek((event) async { + await audioPlayer.seek(event.data); + }); + + event.onShuffle((event) async { + await audioPlayer.setShuffle(event.data); + }); + + event.onLoop((event) async { + await audioPlayer.setLoopMode(event.data); + }); + + event.onAddTrack((event) async { + await playbackNotifier.addTrack(event.data); + }); + + event.onRemoveTrack((event) async { + await playbackNotifier.removeTrack(event.data); + }); + + event.onReorder((event) async { + await playbackNotifier.moveTrack( + event.data.oldIndex, + event.data.newIndex, + ); + }); + + event.onVolume((event) async { + ref.read(volumeProvider.notifier).setVolume(event.data); + }); + } catch (e, stackTrace) { + AppLogger.reportError(e, stackTrace); + channel.sink.addEvent(WebSocketErrorEvent(e.toString())); + } + }, + onDone: () { + logger.i('Connection closed'); + }, + ), + ]); + }, + )(req); + } +} + +final serverConnectRoutesProvider = Provider((ref) => ServerConnectRoutes(ref)); diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart new file mode 100644 index 00000000..dd9d6c3b --- /dev/null +++ b/lib/provider/server/routes/playback.dart @@ -0,0 +1,73 @@ +import 'package:dio/dio.dart' hide Response; +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shelf/shelf.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/server/active_sourced_track.dart'; +import 'package:spotube/provider/server/sourced_track.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/services/logger/logger.dart'; + +class ServerPlaybackRoutes { + final Ref ref; + UserPreferences get userPreferences => ref.read(userPreferencesProvider); + ProxyPlaylist get playlist => ref.read(proxyPlaylistProvider); + final Dio dio; + + ServerPlaybackRoutes(this.ref) : dio = Dio(); + + /// @get('/stream/') + Future getStreamTrackId(Request request, String trackId) async { + try { + final track = + playlist.tracks.firstWhere((element) => element.id == trackId); + final activeSourcedTrack = ref.read(activeSourcedTrackProvider); + final sourcedTrack = activeSourcedTrack?.id == track.id + ? activeSourcedTrack + : await ref.read(sourcedTrackProvider(track).future); + + ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); + + final res = await dio.get( + sourcedTrack!.url, + options: Options( + headers: { + ...request.headers, + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "host": Uri.parse(sourcedTrack.url).host, + "Cache-Control": "max-age=0", + "Connection": "keep-alive", + }, + responseType: ResponseType.stream, + validateStatus: (status) => status! < 500, + ), + ); + + final audioStream = + (res.data?.stream as Stream?)?.asBroadcastStream(); + + audioStream!.listen( + (event) {}, + cancelOnError: true, + ); + + return Response( + res.statusCode!, + body: audioStream, + context: { + "shelf.io.buffer_output": false, + }, + headers: res.headers.map, + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + return Response.internalServerError(); + } + } +} + +final serverPlaybackRoutesProvider = + Provider((ref) => ServerPlaybackRoutes(ref)); diff --git a/lib/provider/server/server.dart b/lib/provider/server/server.dart index b6a7dfe9..5232bb17 100644 --- a/lib/provider/server/server.dart +++ b/lib/provider/server/server.dart @@ -1,119 +1,35 @@ import 'dart:io'; import 'dart:math'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:dio/dio.dart' hide Response; -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logger/logger.dart'; -import 'package:shelf/shelf.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelf/shelf_io.dart'; -import 'package:shelf_router/shelf_router.dart'; -import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/server/active_sourced_track.dart'; -import 'package:spotube/provider/server/sourced_track.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/provider/server/pipeline.dart'; +import 'package:spotube/provider/server/router.dart'; +import 'package:spotube/services/logger/logger.dart'; -class PlaybackServer { - final Ref ref; - UserPreferences get userPreferences => ref.read(userPreferencesProvider); - ProxyPlaylist get playlist => ref.read(proxyPlaylistProvider); - final Logger logger; - final Dio dio; +int serverPort = 0; +final serverProvider = FutureProvider( + (ref) async { + final pipeline = ref.watch(pipelineProvider); + final router = ref.watch(serverRouterProvider); - final Router router; + final port = Random().nextInt(17000) + 1500; - static final port = Random().nextInt(17000) + 1500; + final server = await serve( + pipeline.addHandler(router.call), + InternetAddress.anyIPv4, + port, + ); - PlaybackServer(this.ref) - : logger = getLogger('PlaybackServer'), - dio = Dio(), - router = Router() { - router.get('/stream/', getStreamTrackId); + AppLogger.log + .t('Playback server at http://${server.address.host}:${server.port}'); - const pipeline = Pipeline(); - - if (kDebugMode) { - pipeline.addMiddleware(logRequests()); - } - - serve(pipeline.addHandler(router.call), InternetAddress.loopbackIPv4, port) - .then((server) { - logger - .t('Playback server at http://${server.address.host}:${server.port}'); - - ref.onDispose(() { - dio.close(force: true); - server.close(); - }); + ref.onDispose(() { + server.close(); }); - } - /// @get('/stream/') - Future getStreamTrackId(Request request, String trackId) async { - try { - final track = - playlist.tracks.firstWhere((element) => element.id == trackId); - final activeSourcedTrack = ref.read(activeSourcedTrackProvider); - final sourcedTrack = activeSourcedTrack?.id == track.id - ? activeSourcedTrack - : await ref.read(sourcedTrackProvider(track).future); + serverPort = port; - ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); - - final res = await dio.get( - sourcedTrack!.url, - options: Options( - headers: { - ...request.headers, - "User-Agent": - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "host": Uri.parse(sourcedTrack.url).host, - "Cache-Control": "max-age=0", - "Connection": "keep-alive", - }, - responseType: ResponseType.stream, - validateStatus: (status) => status! < 500, - ), - ); - - final audioStream = - (res.data?.stream as Stream?)?.asBroadcastStream(); - - // if (res.statusCode! > 300) { - // debugPrint( - // "[[Request]]\n" - // "URI: ${res.requestOptions.uri}\n" - // "Status: ${res.statusCode}\n" - // "Request Headers: ${res.requestOptions.headers}\n" - // "Response Body: ${res.data}\n" - // "Response Headers: ${res.headers.map}", - // ); - // } - - audioStream!.listen( - (event) {}, - cancelOnError: true, - ); - - return Response( - res.statusCode!, - body: audioStream, - context: { - "shelf.io.buffer_output": false, - }, - headers: res.headers.map, - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - return Response.internalServerError(); - } - } -} - -final playbackServerProvider = Provider((ref) { - return PlaybackServer(ref); -}); + return (server: server, port: port); + }, +); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 8b391a07..df23039c 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,10 +1,10 @@ import 'dart:io'; +import 'package:spotube/provider/server/server.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:flutter/foundation.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/server/server.dart'; import 'package:spotube/services/audio_player/custom_player.dart'; import 'dart:async'; @@ -27,7 +27,7 @@ class SpotubeMedia extends mk.Media { }) : super( track is LocalTrack ? track.path - : "http://${InternetAddress.loopbackIPv4.address}:${PlaybackServer.port}/stream/${track.id}", + : "http://${InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}", extras: { ...?extras, "track": switch (track) { From 9034ee29dbb1b2dfede3cca6ff76a100e7815c64 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 13 Jun 2024 21:23:12 +0600 Subject: [PATCH 136/261] chore: use flutter version in runner rc --- windows/runner/Runner.rc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 27632667..62e150f8 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -69,7 +69,7 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #if defined(FLUTTER_VERSION) #define VERSION_AS_STRING FLUTTER_VERSION #else -#define VERSION_AS_STRING "3.7.0" +#define VERSION_AS_STRING "1.0.0" #endif VS_VERSION_INFO VERSIONINFO From 2540d16cede192f5b173f9cee0aa383e44f3dec1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 13 Jun 2024 21:43:29 +0600 Subject: [PATCH 137/261] chore: remove circleci config --- .circleci/config.yml | 177 ------------------------------------------- CONTRIBUTION.md | 2 +- 2 files changed, 1 insertion(+), 178 deletions(-) delete mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index a55310ce..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,177 +0,0 @@ -version: 2.1 - -orbs: - gh: circleci/github-cli@2.2.0 - -jobs: - flutter_linux_arm: - machine: - image: ubuntu-2204:current - resource_class: arm.medium - parameters: - version: - type: string - default: 3.1.1 - channel: - type: enum - enum: - - release - - nightly - default: release - github_run_number: - type: string - default: "0" - dry_run: - type: boolean - default: true - steps: - - checkout - - gh/setup - - - run: - name: Get current date - command: | - echo "export CURRENT_DATE=$(date +%Y-%m-%d)" >> $BASH_ENV - - - run: - name: Install dependencies - command: | - sudo apt-get update -y - sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev zip rpm - - - run: - name: Install Flutter - command: | - git clone https://github.com/flutter/flutter.git - cd flutter && git checkout stable && cd .. - export PATH="$PATH:`pwd`/flutter/bin" - flutter precache - flutter doctor -v - - - run: - name: Install AppImageTool - command: | - wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-aarch64.AppImage" - chmod +x appimagetool - mv appimagetool flutter/bin - - - persist_to_workspace: - root: flutter - paths: - - . - - - when: - condition: - equal: [<< parameters.channel >>, nightly] - steps: - - run: - name: Replace pubspec version and BUILD_VERSION Env (nightly) - command: | - curl -sS https://webi.sh/yq | sh - yq -i '.version |= sub("\+\d+", "+<< parameters.channel >>.")' pubspec.yaml - yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - echo 'export BUILD_VERSION="<< parameters.version >>+<< parameters.channel >>.<< parameters.github_run_number >>"' >> $BASH_ENV - - - when: - condition: - equal: [<< parameters.channel >>, release] - steps: - - run: echo 'export BUILD_VERSION="<< parameters.version >>"' >> $BASH_ENV - - - run: - name: Generate .env file - command: | - echo "SPOTIFY_SECRETS=${SPOTIFY_SECRETS}" >> .env - - - run: - name: Replace Version in files - command: | - sed -i 's|%{{APPDATA_RELEASE}}%||' linux/com.github.KRTirtho.Spotube.appdata.xml - echo "build_arch: aarch64" >> linux/packaging/rpm/make_config.yaml - - - run: - name: Build secrets - command: | - export PATH="$PATH:`pwd`/flutter/bin" - flutter config --enable-linux-desktop - flutter pub get - dart run build_runner build --delete-conflicting-outputs --enable-experiment=records,patterns - - - run: - name: Build Flutter app - command: | - export PATH="$PATH:`pwd`/flutter/bin" - export PATH="$PATH":"$HOME/.pub-cache/bin" - 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 - - - when: - condition: - equal: [<< parameters.channel >>, nightly] - steps: - - run: make tar VERSION=nightly ARCH=arm64 PKG_ARCH=aarch64 - - - when: - condition: - equal: [<< parameters.channel >>, release] - steps: - - run: make tar VERSION=${BUILD_VERSION} ARCH=arm64 PKG_ARCH=aarch64 - - - run: - name: Move artifacts - command: | - mkdir bundle - mv build/spotube-linux-*-aarch64.tar.xz bundle/ - mv dist/**/spotube-*-linux.deb bundle/Spotube-linux-aarch64.deb - mv dist/**/spotube-*-linux.rpm bundle/Spotube-linux-aarch64.rpm - mv dist/**/spotube-*-linux.AppImage bundle/Spotube-linux-aarch64.AppImage - zip -r Spotube-linux-aarch64.zip bundle - - - store_artifacts: - path: Spotube-linux-aarch64.zip - - - when: - condition: - and: - - equal: [<< parameters.dry_run >>, false] - - equal: [<< parameters.channel >>, release] - steps: - - run: - name: Upload to release (release) - command: gh release upload v<< parameters.version >> bundle/* --clobber - - - when: - condition: - and: - - equal: [<< parameters.dry_run >>, false] - - equal: [<< parameters.channel >>, nightly] - steps: - - run: - name: Upload to release (nightly) - command: gh release upload nightly bundle/* --clobber - -parameters: - GHA_Actor: - type: string - default: "" - GHA_Action: - type: string - default: "" - GHA_Event: - type: string - default: "" - GHA_Meta: - type: string - default: "" - -workflows: - build_flutter_for_arm_workflow: - when: << pipeline.parameters.GHA_Action >> - jobs: - - flutter_linux_arm: - context: - - org-global - - GITHUB_CREDS diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index e859f9e6..0cfff0ca 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -32,7 +32,7 @@ All types of contributions are encouraged and valued. See the [Table of Contents This project and everyone participating in it is governed by the [Spotube Code of Conduct](https://github.com/KRTirtho/spotube/blob/master/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior -to <>. +to krtirtho@gmail.com. ## I Have a Question From 3fb003ea60d90a8f8da7eb177bc747d4eb219199 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 14 Jun 2024 00:29:09 +0600 Subject: [PATCH 138/261] refactor(preferences): use Drift sql db for preferences --- .../sections/body/track_view_options.dart | 2 +- .../configurators/use_close_behavior.dart | 3 +- lib/models/database/database.dart | 54 + lib/models/database/database.g.dart | 1707 +++++++++++++++++ lib/models/database/tables/preferences.dart | 125 ++ lib/models/database/typeconverters/color.dart | 29 + .../database/typeconverters/locale.dart | 19 + .../database/typeconverters/string_list.dart | 15 + lib/modules/player/sibling_tracks_sheet.dart | 3 +- lib/modules/root/bottom_player.dart | 3 +- lib/modules/root/sidebar.dart | 3 +- lib/modules/root/spotube_navigation_bar.dart | 3 +- .../getting_started/sections/playback.dart | 2 +- .../getting_started/sections/region.dart | 4 +- .../playlist_generate/playlist_generate.dart | 2 +- lib/pages/settings/sections/appearance.dart | 2 +- lib/pages/settings/sections/desktop.dart | 3 +- .../settings/sections/language_region.dart | 2 +- lib/pages/settings/sections/playback.dart | 3 +- lib/provider/database/database.dart | 4 + .../proxy_playlist_provider.dart | 2 +- .../proxy_playlist/skip_segments.dart | 3 +- lib/provider/server/routes/playback.dart | 1 - lib/provider/spotify/album/releases.dart | 4 +- lib/provider/spotify/artist/albums.dart | 4 +- lib/provider/spotify/artist/top_tracks.dart | 3 +- lib/provider/spotify/category/categories.dart | 3 +- lib/provider/spotify/category/playlists.dart | 4 +- lib/provider/spotify/playlist/generate.dart | 2 +- lib/provider/spotify/search/search.dart | 4 +- lib/provider/spotify/views/home.dart | 2 +- lib/provider/spotify/views/home_section.dart | 2 +- lib/provider/spotify/views/view.dart | 2 +- .../user_preferences_provider.dart | 189 +- .../user_preferences_state.dart | 142 -- .../user_preferences_state.freezed.dart | 751 -------- .../user_preferences_state.g.dart | 388 ---- .../sourced_track/models/video_info.dart | 3 +- lib/services/sourced_track/sourced_track.dart | 3 +- lib/services/sourced_track/sources/piped.dart | 3 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 21 + pubspec.lock | 48 + pubspec.yaml | 4 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 48 files changed, 2187 insertions(+), 1400 deletions(-) create mode 100644 lib/models/database/database.dart create mode 100644 lib/models/database/database.g.dart create mode 100644 lib/models/database/tables/preferences.dart create mode 100644 lib/models/database/typeconverters/color.dart create mode 100644 lib/models/database/typeconverters/locale.dart create mode 100644 lib/models/database/typeconverters/string_list.dart create mode 100644 lib/provider/database/database.dart delete mode 100644 lib/provider/user_preferences/user_preferences_state.dart delete mode 100644 lib/provider/user_preferences/user_preferences_state.freezed.dart delete mode 100644 lib/provider/user_preferences/user_preferences_state.g.dart diff --git a/lib/components/tracks_view/sections/body/track_view_options.dart b/lib/components/tracks_view/sections/body/track_view_options.dart index f004b10a..1accba34 100644 --- a/lib/components/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/tracks_view/sections/body/track_view_options.dart @@ -8,11 +8,11 @@ import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart'; import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/components/tracks_view/track_view_provider.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class TrackViewBodyOptions extends HookConsumerWidget { const TrackViewBodyOptions({super.key}); diff --git a/lib/hooks/configurators/use_close_behavior.dart b/lib/hooks/configurators/use_close_behavior.dart index 3df6a528..2bdc65ef 100644 --- a/lib/hooks/configurators/use_close_behavior.dart +++ b/lib/hooks/configurators/use_close_behavior.dart @@ -2,8 +2,9 @@ import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/hooks/configurators/use_window_listener.dart'; +import 'package:spotube/models/database/database.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'; import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart new file mode 100644 index 00000000..7d8fe088 --- /dev/null +++ b/lib/models/database/database.dart @@ -0,0 +1,54 @@ +library database; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; +import 'package:flutter/material.dart' hide Table; +import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; +import 'package:drift/native.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; + +part 'database.g.dart'; + +part 'tables/preferences.dart'; +part 'typeconverters/color.dart'; +part 'typeconverters/locale.dart'; +part 'typeconverters/string_list.dart'; + +@DriftDatabase(tables: [PreferencesTable]) +class AppDatabase extends _$AppDatabase { + AppDatabase() : super(_openConnection()); + + @override + int get schemaVersion => 1; +} + +LazyDatabase _openConnection() { + // the LazyDatabase util lets us find the right location for the file async. + return LazyDatabase(() async { + // put the database file, called db.sqlite here, into the documents folder + // for your app. + final dbFolder = await getApplicationDocumentsDirectory(); + final file = File(join(dbFolder.path, 'db.sqlite')); + + // Also work around limitations on old Android versions + if (Platform.isAndroid) { + await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); + } + + // Make sqlite3 pick a more suitable location for temporary files - the + // one from the system may be inaccessible due to sandboxing. + final cacheBase = (await getTemporaryDirectory()).path; + // We can't access /tmp on Android, which sqlite3 would try by default. + // Explicitly tell it about the correct temporary directory. + sqlite3.tempDirectory = cacheBase; + + return NativeDatabase.createInBackground(file); + }); +} diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart new file mode 100644 index 00000000..1516b266 --- /dev/null +++ b/lib/models/database/database.g.dart @@ -0,0 +1,1707 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class $PreferencesTableTable extends PreferencesTable + with TableInfo<$PreferencesTableTable, PreferencesTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PreferencesTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _audioQualityMeta = + const VerificationMeta('audioQuality'); + @override + late final GeneratedColumnWithTypeConverter + audioQuality = GeneratedColumn( + 'audio_quality', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(SourceQualities.high.name)) + .withConverter( + $PreferencesTableTable.$converteraudioQuality); + static const VerificationMeta _albumColorSyncMeta = + const VerificationMeta('albumColorSync'); + @override + late final GeneratedColumn albumColorSync = GeneratedColumn( + 'album_color_sync', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("album_color_sync" IN (0, 1))'), + defaultValue: const Constant(true)); + static const VerificationMeta _amoledDarkThemeMeta = + const VerificationMeta('amoledDarkTheme'); + @override + late final GeneratedColumn amoledDarkTheme = GeneratedColumn( + 'amoled_dark_theme', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("amoled_dark_theme" IN (0, 1))'), + defaultValue: const Constant(false)); + static const VerificationMeta _checkUpdateMeta = + const VerificationMeta('checkUpdate'); + @override + late final GeneratedColumn checkUpdate = GeneratedColumn( + 'check_update', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("check_update" IN (0, 1))'), + defaultValue: const Constant(true)); + static const VerificationMeta _normalizeAudioMeta = + const VerificationMeta('normalizeAudio'); + @override + late final GeneratedColumn normalizeAudio = GeneratedColumn( + 'normalize_audio', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("normalize_audio" IN (0, 1))'), + defaultValue: const Constant(false)); + static const VerificationMeta _showSystemTrayIconMeta = + const VerificationMeta('showSystemTrayIcon'); + @override + late final GeneratedColumn showSystemTrayIcon = GeneratedColumn( + 'show_system_tray_icon', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("show_system_tray_icon" IN (0, 1))'), + defaultValue: const Constant(false)); + static const VerificationMeta _systemTitleBarMeta = + const VerificationMeta('systemTitleBar'); + @override + late final GeneratedColumn systemTitleBar = GeneratedColumn( + 'system_title_bar', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("system_title_bar" IN (0, 1))'), + defaultValue: const Constant(false)); + static const VerificationMeta _skipNonMusicMeta = + const VerificationMeta('skipNonMusic'); + @override + late final GeneratedColumn skipNonMusic = GeneratedColumn( + 'skip_non_music', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("skip_non_music" IN (0, 1))'), + defaultValue: const Constant(false)); + static const VerificationMeta _closeBehaviorMeta = + const VerificationMeta('closeBehavior'); + @override + late final GeneratedColumnWithTypeConverter + closeBehavior = GeneratedColumn( + 'close_behavior', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(CloseBehavior.close.name)) + .withConverter( + $PreferencesTableTable.$convertercloseBehavior); + static const VerificationMeta _accentColorSchemeMeta = + const VerificationMeta('accentColorScheme'); + @override + late final GeneratedColumnWithTypeConverter + accentColorScheme = GeneratedColumn( + 'accent_color_scheme', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant("Blue:0xFF2196F3")) + .withConverter( + $PreferencesTableTable.$converteraccentColorScheme); + static const VerificationMeta _layoutModeMeta = + const VerificationMeta('layoutMode'); + @override + late final GeneratedColumnWithTypeConverter layoutMode = + GeneratedColumn('layout_mode', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(LayoutMode.adaptive.name)) + .withConverter( + $PreferencesTableTable.$converterlayoutMode); + static const VerificationMeta _localeMeta = const VerificationMeta('locale'); + @override + late final GeneratedColumnWithTypeConverter locale = + GeneratedColumn('locale', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant( + '{"languageCode":"system","countryCode":"system"}')) + .withConverter($PreferencesTableTable.$converterlocale); + static const VerificationMeta _marketMeta = const VerificationMeta('market'); + @override + late final GeneratedColumnWithTypeConverter market = + GeneratedColumn('market', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(Market.US.name)) + .withConverter($PreferencesTableTable.$convertermarket); + static const VerificationMeta _searchModeMeta = + const VerificationMeta('searchMode'); + @override + late final GeneratedColumnWithTypeConverter searchMode = + GeneratedColumn('search_mode', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(SearchMode.youtube.name)) + .withConverter( + $PreferencesTableTable.$convertersearchMode); + static const VerificationMeta _downloadLocationMeta = + const VerificationMeta('downloadLocation'); + @override + late final GeneratedColumn downloadLocation = GeneratedColumn( + 'download_location', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant("")); + static const VerificationMeta _localLibraryLocationMeta = + const VerificationMeta('localLibraryLocation'); + @override + late final GeneratedColumnWithTypeConverter, String> + localLibraryLocation = GeneratedColumn( + 'local_library_location', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant("")) + .withConverter>( + $PreferencesTableTable.$converterlocalLibraryLocation); + static const VerificationMeta _pipedInstanceMeta = + const VerificationMeta('pipedInstance'); + @override + late final GeneratedColumn pipedInstance = GeneratedColumn( + 'piped_instance', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant("https://pipedapi.kavin.rocks")); + static const VerificationMeta _themeModeMeta = + const VerificationMeta('themeMode'); + @override + late final GeneratedColumnWithTypeConverter themeMode = + GeneratedColumn('theme_mode', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(ThemeMode.system.name)) + .withConverter($PreferencesTableTable.$converterthemeMode); + static const VerificationMeta _audioSourceMeta = + const VerificationMeta('audioSource'); + @override + late final GeneratedColumnWithTypeConverter audioSource = + GeneratedColumn('audio_source', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(AudioSource.youtube.name)) + .withConverter( + $PreferencesTableTable.$converteraudioSource); + static const VerificationMeta _streamMusicCodecMeta = + const VerificationMeta('streamMusicCodec'); + @override + late final GeneratedColumnWithTypeConverter + streamMusicCodec = GeneratedColumn( + 'stream_music_codec', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(SourceCodecs.weba.name)) + .withConverter( + $PreferencesTableTable.$converterstreamMusicCodec); + static const VerificationMeta _downloadMusicCodecMeta = + const VerificationMeta('downloadMusicCodec'); + @override + late final GeneratedColumnWithTypeConverter + downloadMusicCodec = GeneratedColumn( + 'download_music_codec', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(SourceCodecs.m4a.name)) + .withConverter( + $PreferencesTableTable.$converterdownloadMusicCodec); + static const VerificationMeta _discordPresenceMeta = + const VerificationMeta('discordPresence'); + @override + late final GeneratedColumn discordPresence = GeneratedColumn( + 'discord_presence', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("discord_presence" IN (0, 1))'), + defaultValue: const Constant(true)); + static const VerificationMeta _endlessPlaybackMeta = + const VerificationMeta('endlessPlayback'); + @override + late final GeneratedColumn endlessPlayback = GeneratedColumn( + 'endless_playback', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("endless_playback" IN (0, 1))'), + defaultValue: const Constant(true)); + static const VerificationMeta _enableConnectMeta = + const VerificationMeta('enableConnect'); + @override + late final GeneratedColumn enableConnect = GeneratedColumn( + 'enable_connect', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("enable_connect" IN (0, 1))'), + defaultValue: const Constant(false)); + @override + List get $columns => [ + id, + audioQuality, + albumColorSync, + amoledDarkTheme, + checkUpdate, + normalizeAudio, + showSystemTrayIcon, + systemTitleBar, + skipNonMusic, + closeBehavior, + accentColorScheme, + layoutMode, + locale, + market, + searchMode, + downloadLocation, + localLibraryLocation, + pipedInstance, + themeMode, + audioSource, + streamMusicCodec, + downloadMusicCodec, + discordPresence, + endlessPlayback, + enableConnect + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'preferences_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + context.handle(_audioQualityMeta, const VerificationResult.success()); + if (data.containsKey('album_color_sync')) { + context.handle( + _albumColorSyncMeta, + albumColorSync.isAcceptableOrUnknown( + data['album_color_sync']!, _albumColorSyncMeta)); + } + if (data.containsKey('amoled_dark_theme')) { + context.handle( + _amoledDarkThemeMeta, + amoledDarkTheme.isAcceptableOrUnknown( + data['amoled_dark_theme']!, _amoledDarkThemeMeta)); + } + if (data.containsKey('check_update')) { + context.handle( + _checkUpdateMeta, + checkUpdate.isAcceptableOrUnknown( + data['check_update']!, _checkUpdateMeta)); + } + if (data.containsKey('normalize_audio')) { + context.handle( + _normalizeAudioMeta, + normalizeAudio.isAcceptableOrUnknown( + data['normalize_audio']!, _normalizeAudioMeta)); + } + if (data.containsKey('show_system_tray_icon')) { + context.handle( + _showSystemTrayIconMeta, + showSystemTrayIcon.isAcceptableOrUnknown( + data['show_system_tray_icon']!, _showSystemTrayIconMeta)); + } + if (data.containsKey('system_title_bar')) { + context.handle( + _systemTitleBarMeta, + systemTitleBar.isAcceptableOrUnknown( + data['system_title_bar']!, _systemTitleBarMeta)); + } + if (data.containsKey('skip_non_music')) { + context.handle( + _skipNonMusicMeta, + skipNonMusic.isAcceptableOrUnknown( + data['skip_non_music']!, _skipNonMusicMeta)); + } + context.handle(_closeBehaviorMeta, const VerificationResult.success()); + context.handle(_accentColorSchemeMeta, const VerificationResult.success()); + context.handle(_layoutModeMeta, const VerificationResult.success()); + context.handle(_localeMeta, const VerificationResult.success()); + context.handle(_marketMeta, const VerificationResult.success()); + context.handle(_searchModeMeta, const VerificationResult.success()); + if (data.containsKey('download_location')) { + context.handle( + _downloadLocationMeta, + downloadLocation.isAcceptableOrUnknown( + data['download_location']!, _downloadLocationMeta)); + } + context.handle( + _localLibraryLocationMeta, const VerificationResult.success()); + if (data.containsKey('piped_instance')) { + context.handle( + _pipedInstanceMeta, + pipedInstance.isAcceptableOrUnknown( + data['piped_instance']!, _pipedInstanceMeta)); + } + context.handle(_themeModeMeta, const VerificationResult.success()); + context.handle(_audioSourceMeta, const VerificationResult.success()); + context.handle(_streamMusicCodecMeta, const VerificationResult.success()); + context.handle(_downloadMusicCodecMeta, const VerificationResult.success()); + if (data.containsKey('discord_presence')) { + context.handle( + _discordPresenceMeta, + discordPresence.isAcceptableOrUnknown( + data['discord_presence']!, _discordPresenceMeta)); + } + if (data.containsKey('endless_playback')) { + context.handle( + _endlessPlaybackMeta, + endlessPlayback.isAcceptableOrUnknown( + data['endless_playback']!, _endlessPlaybackMeta)); + } + if (data.containsKey('enable_connect')) { + context.handle( + _enableConnectMeta, + enableConnect.isAcceptableOrUnknown( + data['enable_connect']!, _enableConnectMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PreferencesTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PreferencesTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + audioQuality: $PreferencesTableTable.$converteraudioQuality.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}audio_quality'])!), + albumColorSync: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}album_color_sync'])!, + amoledDarkTheme: attachedDatabase.typeMapping.read( + DriftSqlType.bool, data['${effectivePrefix}amoled_dark_theme'])!, + checkUpdate: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}check_update'])!, + normalizeAudio: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}normalize_audio'])!, + showSystemTrayIcon: attachedDatabase.typeMapping.read( + DriftSqlType.bool, data['${effectivePrefix}show_system_tray_icon'])!, + systemTitleBar: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}system_title_bar'])!, + skipNonMusic: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}skip_non_music'])!, + closeBehavior: $PreferencesTableTable.$convertercloseBehavior.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}close_behavior'])!), + accentColorScheme: $PreferencesTableTable.$converteraccentColorScheme + .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, + data['${effectivePrefix}accent_color_scheme'])!), + layoutMode: $PreferencesTableTable.$converterlayoutMode.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}layout_mode'])!), + locale: $PreferencesTableTable.$converterlocale.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}locale'])!), + market: $PreferencesTableTable.$convertermarket.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}market'])!), + searchMode: $PreferencesTableTable.$convertersearchMode.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}search_mode'])!), + downloadLocation: attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}download_location'])!, + localLibraryLocation: $PreferencesTableTable + .$converterlocalLibraryLocation + .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, + data['${effectivePrefix}local_library_location'])!), + pipedInstance: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}piped_instance'])!, + themeMode: $PreferencesTableTable.$converterthemeMode.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}theme_mode'])!), + audioSource: $PreferencesTableTable.$converteraudioSource.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}audio_source'])!), + streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec + .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, + data['${effectivePrefix}stream_music_codec'])!), + downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec + .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, + data['${effectivePrefix}download_music_codec'])!), + discordPresence: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}discord_presence'])!, + endlessPlayback: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}endless_playback'])!, + enableConnect: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}enable_connect'])!, + ); + } + + @override + $PreferencesTableTable createAlias(String alias) { + return $PreferencesTableTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 + $converteraudioQuality = + const EnumNameConverter(SourceQualities.values); + static JsonTypeConverter2 + $convertercloseBehavior = + const EnumNameConverter(CloseBehavior.values); + static TypeConverter $converteraccentColorScheme = + const SpotubeColorConverter(); + static JsonTypeConverter2 $converterlayoutMode = + const EnumNameConverter(LayoutMode.values); + static TypeConverter $converterlocale = + const LocaleConverter(); + static JsonTypeConverter2 $convertermarket = + const EnumNameConverter(Market.values); + static JsonTypeConverter2 $convertersearchMode = + const EnumNameConverter(SearchMode.values); + static TypeConverter, String> $converterlocalLibraryLocation = + const StringListConverter(); + static JsonTypeConverter2 $converterthemeMode = + const EnumNameConverter(ThemeMode.values); + static JsonTypeConverter2 $converteraudioSource = + const EnumNameConverter(AudioSource.values); + static JsonTypeConverter2 + $converterstreamMusicCodec = + const EnumNameConverter(SourceCodecs.values); + static JsonTypeConverter2 + $converterdownloadMusicCodec = + const EnumNameConverter(SourceCodecs.values); +} + +class PreferencesTableData extends DataClass + implements Insertable { + final int id; + final SourceQualities audioQuality; + final bool albumColorSync; + final bool amoledDarkTheme; + final bool checkUpdate; + final bool normalizeAudio; + final bool showSystemTrayIcon; + final bool systemTitleBar; + final bool skipNonMusic; + final CloseBehavior closeBehavior; + final SpotubeColor accentColorScheme; + final LayoutMode layoutMode; + final Locale locale; + final Market market; + final SearchMode searchMode; + final String downloadLocation; + final List localLibraryLocation; + final String pipedInstance; + final ThemeMode themeMode; + final AudioSource audioSource; + final SourceCodecs streamMusicCodec; + final SourceCodecs downloadMusicCodec; + final bool discordPresence; + final bool endlessPlayback; + final bool enableConnect; + const PreferencesTableData( + {required this.id, + required this.audioQuality, + required this.albumColorSync, + required this.amoledDarkTheme, + required this.checkUpdate, + required this.normalizeAudio, + required this.showSystemTrayIcon, + required this.systemTitleBar, + required this.skipNonMusic, + required this.closeBehavior, + required this.accentColorScheme, + required this.layoutMode, + required this.locale, + required this.market, + required this.searchMode, + required this.downloadLocation, + required this.localLibraryLocation, + required this.pipedInstance, + required this.themeMode, + required this.audioSource, + required this.streamMusicCodec, + required this.downloadMusicCodec, + required this.discordPresence, + required this.endlessPlayback, + required this.enableConnect}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + { + map['audio_quality'] = Variable( + $PreferencesTableTable.$converteraudioQuality.toSql(audioQuality)); + } + map['album_color_sync'] = Variable(albumColorSync); + map['amoled_dark_theme'] = Variable(amoledDarkTheme); + map['check_update'] = Variable(checkUpdate); + map['normalize_audio'] = Variable(normalizeAudio); + map['show_system_tray_icon'] = Variable(showSystemTrayIcon); + map['system_title_bar'] = Variable(systemTitleBar); + map['skip_non_music'] = Variable(skipNonMusic); + { + map['close_behavior'] = Variable( + $PreferencesTableTable.$convertercloseBehavior.toSql(closeBehavior)); + } + { + map['accent_color_scheme'] = Variable($PreferencesTableTable + .$converteraccentColorScheme + .toSql(accentColorScheme)); + } + { + map['layout_mode'] = Variable( + $PreferencesTableTable.$converterlayoutMode.toSql(layoutMode)); + } + { + map['locale'] = Variable( + $PreferencesTableTable.$converterlocale.toSql(locale)); + } + { + map['market'] = Variable( + $PreferencesTableTable.$convertermarket.toSql(market)); + } + { + map['search_mode'] = Variable( + $PreferencesTableTable.$convertersearchMode.toSql(searchMode)); + } + map['download_location'] = Variable(downloadLocation); + { + map['local_library_location'] = Variable($PreferencesTableTable + .$converterlocalLibraryLocation + .toSql(localLibraryLocation)); + } + map['piped_instance'] = Variable(pipedInstance); + { + map['theme_mode'] = Variable( + $PreferencesTableTable.$converterthemeMode.toSql(themeMode)); + } + { + map['audio_source'] = Variable( + $PreferencesTableTable.$converteraudioSource.toSql(audioSource)); + } + { + map['stream_music_codec'] = Variable($PreferencesTableTable + .$converterstreamMusicCodec + .toSql(streamMusicCodec)); + } + { + map['download_music_codec'] = Variable($PreferencesTableTable + .$converterdownloadMusicCodec + .toSql(downloadMusicCodec)); + } + map['discord_presence'] = Variable(discordPresence); + map['endless_playback'] = Variable(endlessPlayback); + map['enable_connect'] = Variable(enableConnect); + return map; + } + + PreferencesTableCompanion toCompanion(bool nullToAbsent) { + return PreferencesTableCompanion( + id: Value(id), + audioQuality: Value(audioQuality), + albumColorSync: Value(albumColorSync), + amoledDarkTheme: Value(amoledDarkTheme), + checkUpdate: Value(checkUpdate), + normalizeAudio: Value(normalizeAudio), + showSystemTrayIcon: Value(showSystemTrayIcon), + systemTitleBar: Value(systemTitleBar), + skipNonMusic: Value(skipNonMusic), + closeBehavior: Value(closeBehavior), + accentColorScheme: Value(accentColorScheme), + layoutMode: Value(layoutMode), + locale: Value(locale), + market: Value(market), + searchMode: Value(searchMode), + downloadLocation: Value(downloadLocation), + localLibraryLocation: Value(localLibraryLocation), + pipedInstance: Value(pipedInstance), + themeMode: Value(themeMode), + audioSource: Value(audioSource), + streamMusicCodec: Value(streamMusicCodec), + downloadMusicCodec: Value(downloadMusicCodec), + discordPresence: Value(discordPresence), + endlessPlayback: Value(endlessPlayback), + enableConnect: Value(enableConnect), + ); + } + + factory PreferencesTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PreferencesTableData( + id: serializer.fromJson(json['id']), + audioQuality: $PreferencesTableTable.$converteraudioQuality + .fromJson(serializer.fromJson(json['audioQuality'])), + albumColorSync: serializer.fromJson(json['albumColorSync']), + amoledDarkTheme: serializer.fromJson(json['amoledDarkTheme']), + checkUpdate: serializer.fromJson(json['checkUpdate']), + normalizeAudio: serializer.fromJson(json['normalizeAudio']), + showSystemTrayIcon: serializer.fromJson(json['showSystemTrayIcon']), + systemTitleBar: serializer.fromJson(json['systemTitleBar']), + skipNonMusic: serializer.fromJson(json['skipNonMusic']), + closeBehavior: $PreferencesTableTable.$convertercloseBehavior + .fromJson(serializer.fromJson(json['closeBehavior'])), + accentColorScheme: + serializer.fromJson(json['accentColorScheme']), + layoutMode: $PreferencesTableTable.$converterlayoutMode + .fromJson(serializer.fromJson(json['layoutMode'])), + locale: serializer.fromJson(json['locale']), + market: $PreferencesTableTable.$convertermarket + .fromJson(serializer.fromJson(json['market'])), + searchMode: $PreferencesTableTable.$convertersearchMode + .fromJson(serializer.fromJson(json['searchMode'])), + downloadLocation: serializer.fromJson(json['downloadLocation']), + localLibraryLocation: + serializer.fromJson>(json['localLibraryLocation']), + pipedInstance: serializer.fromJson(json['pipedInstance']), + themeMode: $PreferencesTableTable.$converterthemeMode + .fromJson(serializer.fromJson(json['themeMode'])), + audioSource: $PreferencesTableTable.$converteraudioSource + .fromJson(serializer.fromJson(json['audioSource'])), + streamMusicCodec: $PreferencesTableTable.$converterstreamMusicCodec + .fromJson(serializer.fromJson(json['streamMusicCodec'])), + downloadMusicCodec: $PreferencesTableTable.$converterdownloadMusicCodec + .fromJson(serializer.fromJson(json['downloadMusicCodec'])), + discordPresence: serializer.fromJson(json['discordPresence']), + endlessPlayback: serializer.fromJson(json['endlessPlayback']), + enableConnect: serializer.fromJson(json['enableConnect']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'audioQuality': serializer.toJson( + $PreferencesTableTable.$converteraudioQuality.toJson(audioQuality)), + 'albumColorSync': serializer.toJson(albumColorSync), + 'amoledDarkTheme': serializer.toJson(amoledDarkTheme), + 'checkUpdate': serializer.toJson(checkUpdate), + 'normalizeAudio': serializer.toJson(normalizeAudio), + 'showSystemTrayIcon': serializer.toJson(showSystemTrayIcon), + 'systemTitleBar': serializer.toJson(systemTitleBar), + 'skipNonMusic': serializer.toJson(skipNonMusic), + 'closeBehavior': serializer.toJson( + $PreferencesTableTable.$convertercloseBehavior.toJson(closeBehavior)), + 'accentColorScheme': serializer.toJson(accentColorScheme), + 'layoutMode': serializer.toJson( + $PreferencesTableTable.$converterlayoutMode.toJson(layoutMode)), + 'locale': serializer.toJson(locale), + 'market': serializer.toJson( + $PreferencesTableTable.$convertermarket.toJson(market)), + 'searchMode': serializer.toJson( + $PreferencesTableTable.$convertersearchMode.toJson(searchMode)), + 'downloadLocation': serializer.toJson(downloadLocation), + 'localLibraryLocation': + serializer.toJson>(localLibraryLocation), + 'pipedInstance': serializer.toJson(pipedInstance), + 'themeMode': serializer.toJson( + $PreferencesTableTable.$converterthemeMode.toJson(themeMode)), + 'audioSource': serializer.toJson( + $PreferencesTableTable.$converteraudioSource.toJson(audioSource)), + 'streamMusicCodec': serializer.toJson($PreferencesTableTable + .$converterstreamMusicCodec + .toJson(streamMusicCodec)), + 'downloadMusicCodec': serializer.toJson($PreferencesTableTable + .$converterdownloadMusicCodec + .toJson(downloadMusicCodec)), + 'discordPresence': serializer.toJson(discordPresence), + 'endlessPlayback': serializer.toJson(endlessPlayback), + 'enableConnect': serializer.toJson(enableConnect), + }; + } + + PreferencesTableData copyWith( + {int? id, + SourceQualities? audioQuality, + bool? albumColorSync, + bool? amoledDarkTheme, + bool? checkUpdate, + bool? normalizeAudio, + bool? showSystemTrayIcon, + bool? systemTitleBar, + bool? skipNonMusic, + CloseBehavior? closeBehavior, + SpotubeColor? accentColorScheme, + LayoutMode? layoutMode, + Locale? locale, + Market? market, + SearchMode? searchMode, + String? downloadLocation, + List? localLibraryLocation, + String? pipedInstance, + ThemeMode? themeMode, + AudioSource? audioSource, + SourceCodecs? streamMusicCodec, + SourceCodecs? downloadMusicCodec, + bool? discordPresence, + bool? endlessPlayback, + bool? enableConnect}) => + PreferencesTableData( + id: id ?? this.id, + audioQuality: audioQuality ?? this.audioQuality, + albumColorSync: albumColorSync ?? this.albumColorSync, + amoledDarkTheme: amoledDarkTheme ?? this.amoledDarkTheme, + checkUpdate: checkUpdate ?? this.checkUpdate, + normalizeAudio: normalizeAudio ?? this.normalizeAudio, + showSystemTrayIcon: showSystemTrayIcon ?? this.showSystemTrayIcon, + systemTitleBar: systemTitleBar ?? this.systemTitleBar, + skipNonMusic: skipNonMusic ?? this.skipNonMusic, + closeBehavior: closeBehavior ?? this.closeBehavior, + accentColorScheme: accentColorScheme ?? this.accentColorScheme, + layoutMode: layoutMode ?? this.layoutMode, + locale: locale ?? this.locale, + market: market ?? this.market, + searchMode: searchMode ?? this.searchMode, + downloadLocation: downloadLocation ?? this.downloadLocation, + localLibraryLocation: localLibraryLocation ?? this.localLibraryLocation, + pipedInstance: pipedInstance ?? this.pipedInstance, + themeMode: themeMode ?? this.themeMode, + audioSource: audioSource ?? this.audioSource, + streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, + downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, + discordPresence: discordPresence ?? this.discordPresence, + endlessPlayback: endlessPlayback ?? this.endlessPlayback, + enableConnect: enableConnect ?? this.enableConnect, + ); + @override + String toString() { + return (StringBuffer('PreferencesTableData(') + ..write('id: $id, ') + ..write('audioQuality: $audioQuality, ') + ..write('albumColorSync: $albumColorSync, ') + ..write('amoledDarkTheme: $amoledDarkTheme, ') + ..write('checkUpdate: $checkUpdate, ') + ..write('normalizeAudio: $normalizeAudio, ') + ..write('showSystemTrayIcon: $showSystemTrayIcon, ') + ..write('systemTitleBar: $systemTitleBar, ') + ..write('skipNonMusic: $skipNonMusic, ') + ..write('closeBehavior: $closeBehavior, ') + ..write('accentColorScheme: $accentColorScheme, ') + ..write('layoutMode: $layoutMode, ') + ..write('locale: $locale, ') + ..write('market: $market, ') + ..write('searchMode: $searchMode, ') + ..write('downloadLocation: $downloadLocation, ') + ..write('localLibraryLocation: $localLibraryLocation, ') + ..write('pipedInstance: $pipedInstance, ') + ..write('themeMode: $themeMode, ') + ..write('audioSource: $audioSource, ') + ..write('streamMusicCodec: $streamMusicCodec, ') + ..write('downloadMusicCodec: $downloadMusicCodec, ') + ..write('discordPresence: $discordPresence, ') + ..write('endlessPlayback: $endlessPlayback, ') + ..write('enableConnect: $enableConnect') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hashAll([ + id, + audioQuality, + albumColorSync, + amoledDarkTheme, + checkUpdate, + normalizeAudio, + showSystemTrayIcon, + systemTitleBar, + skipNonMusic, + closeBehavior, + accentColorScheme, + layoutMode, + locale, + market, + searchMode, + downloadLocation, + localLibraryLocation, + pipedInstance, + themeMode, + audioSource, + streamMusicCodec, + downloadMusicCodec, + discordPresence, + endlessPlayback, + enableConnect + ]); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PreferencesTableData && + other.id == this.id && + other.audioQuality == this.audioQuality && + other.albumColorSync == this.albumColorSync && + other.amoledDarkTheme == this.amoledDarkTheme && + other.checkUpdate == this.checkUpdate && + other.normalizeAudio == this.normalizeAudio && + other.showSystemTrayIcon == this.showSystemTrayIcon && + other.systemTitleBar == this.systemTitleBar && + other.skipNonMusic == this.skipNonMusic && + other.closeBehavior == this.closeBehavior && + other.accentColorScheme == this.accentColorScheme && + other.layoutMode == this.layoutMode && + other.locale == this.locale && + other.market == this.market && + other.searchMode == this.searchMode && + other.downloadLocation == this.downloadLocation && + other.localLibraryLocation == this.localLibraryLocation && + other.pipedInstance == this.pipedInstance && + other.themeMode == this.themeMode && + other.audioSource == this.audioSource && + other.streamMusicCodec == this.streamMusicCodec && + other.downloadMusicCodec == this.downloadMusicCodec && + other.discordPresence == this.discordPresence && + other.endlessPlayback == this.endlessPlayback && + other.enableConnect == this.enableConnect); +} + +class PreferencesTableCompanion extends UpdateCompanion { + final Value id; + final Value audioQuality; + final Value albumColorSync; + final Value amoledDarkTheme; + final Value checkUpdate; + final Value normalizeAudio; + final Value showSystemTrayIcon; + final Value systemTitleBar; + final Value skipNonMusic; + final Value closeBehavior; + final Value accentColorScheme; + final Value layoutMode; + final Value locale; + final Value market; + final Value searchMode; + final Value downloadLocation; + final Value> localLibraryLocation; + final Value pipedInstance; + final Value themeMode; + final Value audioSource; + final Value streamMusicCodec; + final Value downloadMusicCodec; + final Value discordPresence; + final Value endlessPlayback; + final Value enableConnect; + const PreferencesTableCompanion({ + this.id = const Value.absent(), + this.audioQuality = const Value.absent(), + this.albumColorSync = const Value.absent(), + this.amoledDarkTheme = const Value.absent(), + this.checkUpdate = const Value.absent(), + this.normalizeAudio = const Value.absent(), + this.showSystemTrayIcon = const Value.absent(), + this.systemTitleBar = const Value.absent(), + this.skipNonMusic = const Value.absent(), + this.closeBehavior = const Value.absent(), + this.accentColorScheme = const Value.absent(), + this.layoutMode = const Value.absent(), + this.locale = const Value.absent(), + this.market = const Value.absent(), + this.searchMode = const Value.absent(), + this.downloadLocation = const Value.absent(), + this.localLibraryLocation = const Value.absent(), + this.pipedInstance = const Value.absent(), + this.themeMode = const Value.absent(), + this.audioSource = const Value.absent(), + this.streamMusicCodec = const Value.absent(), + this.downloadMusicCodec = const Value.absent(), + this.discordPresence = const Value.absent(), + this.endlessPlayback = const Value.absent(), + this.enableConnect = const Value.absent(), + }); + PreferencesTableCompanion.insert({ + this.id = const Value.absent(), + this.audioQuality = const Value.absent(), + this.albumColorSync = const Value.absent(), + this.amoledDarkTheme = const Value.absent(), + this.checkUpdate = const Value.absent(), + this.normalizeAudio = const Value.absent(), + this.showSystemTrayIcon = const Value.absent(), + this.systemTitleBar = const Value.absent(), + this.skipNonMusic = const Value.absent(), + this.closeBehavior = const Value.absent(), + this.accentColorScheme = const Value.absent(), + this.layoutMode = const Value.absent(), + this.locale = const Value.absent(), + this.market = const Value.absent(), + this.searchMode = const Value.absent(), + this.downloadLocation = const Value.absent(), + this.localLibraryLocation = const Value.absent(), + this.pipedInstance = const Value.absent(), + this.themeMode = const Value.absent(), + this.audioSource = const Value.absent(), + this.streamMusicCodec = const Value.absent(), + this.downloadMusicCodec = const Value.absent(), + this.discordPresence = const Value.absent(), + this.endlessPlayback = const Value.absent(), + this.enableConnect = const Value.absent(), + }); + static Insertable custom({ + Expression? id, + Expression? audioQuality, + Expression? albumColorSync, + Expression? amoledDarkTheme, + Expression? checkUpdate, + Expression? normalizeAudio, + Expression? showSystemTrayIcon, + Expression? systemTitleBar, + Expression? skipNonMusic, + Expression? closeBehavior, + Expression? accentColorScheme, + Expression? layoutMode, + Expression? locale, + Expression? market, + Expression? searchMode, + Expression? downloadLocation, + Expression? localLibraryLocation, + Expression? pipedInstance, + Expression? themeMode, + Expression? audioSource, + Expression? streamMusicCodec, + Expression? downloadMusicCodec, + Expression? discordPresence, + Expression? endlessPlayback, + Expression? enableConnect, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (audioQuality != null) 'audio_quality': audioQuality, + if (albumColorSync != null) 'album_color_sync': albumColorSync, + if (amoledDarkTheme != null) 'amoled_dark_theme': amoledDarkTheme, + if (checkUpdate != null) 'check_update': checkUpdate, + if (normalizeAudio != null) 'normalize_audio': normalizeAudio, + if (showSystemTrayIcon != null) + 'show_system_tray_icon': showSystemTrayIcon, + if (systemTitleBar != null) 'system_title_bar': systemTitleBar, + if (skipNonMusic != null) 'skip_non_music': skipNonMusic, + if (closeBehavior != null) 'close_behavior': closeBehavior, + if (accentColorScheme != null) 'accent_color_scheme': accentColorScheme, + if (layoutMode != null) 'layout_mode': layoutMode, + if (locale != null) 'locale': locale, + if (market != null) 'market': market, + if (searchMode != null) 'search_mode': searchMode, + if (downloadLocation != null) 'download_location': downloadLocation, + if (localLibraryLocation != null) + 'local_library_location': localLibraryLocation, + if (pipedInstance != null) 'piped_instance': pipedInstance, + if (themeMode != null) 'theme_mode': themeMode, + if (audioSource != null) 'audio_source': audioSource, + if (streamMusicCodec != null) 'stream_music_codec': streamMusicCodec, + if (downloadMusicCodec != null) + 'download_music_codec': downloadMusicCodec, + if (discordPresence != null) 'discord_presence': discordPresence, + if (endlessPlayback != null) 'endless_playback': endlessPlayback, + if (enableConnect != null) 'enable_connect': enableConnect, + }); + } + + PreferencesTableCompanion copyWith( + {Value? id, + Value? audioQuality, + Value? albumColorSync, + Value? amoledDarkTheme, + Value? checkUpdate, + Value? normalizeAudio, + Value? showSystemTrayIcon, + Value? systemTitleBar, + Value? skipNonMusic, + Value? closeBehavior, + Value? accentColorScheme, + Value? layoutMode, + Value? locale, + Value? market, + Value? searchMode, + Value? downloadLocation, + Value>? localLibraryLocation, + Value? pipedInstance, + Value? themeMode, + Value? audioSource, + Value? streamMusicCodec, + Value? downloadMusicCodec, + Value? discordPresence, + Value? endlessPlayback, + Value? enableConnect}) { + return PreferencesTableCompanion( + id: id ?? this.id, + audioQuality: audioQuality ?? this.audioQuality, + albumColorSync: albumColorSync ?? this.albumColorSync, + amoledDarkTheme: amoledDarkTheme ?? this.amoledDarkTheme, + checkUpdate: checkUpdate ?? this.checkUpdate, + normalizeAudio: normalizeAudio ?? this.normalizeAudio, + showSystemTrayIcon: showSystemTrayIcon ?? this.showSystemTrayIcon, + systemTitleBar: systemTitleBar ?? this.systemTitleBar, + skipNonMusic: skipNonMusic ?? this.skipNonMusic, + closeBehavior: closeBehavior ?? this.closeBehavior, + accentColorScheme: accentColorScheme ?? this.accentColorScheme, + layoutMode: layoutMode ?? this.layoutMode, + locale: locale ?? this.locale, + market: market ?? this.market, + searchMode: searchMode ?? this.searchMode, + downloadLocation: downloadLocation ?? this.downloadLocation, + localLibraryLocation: localLibraryLocation ?? this.localLibraryLocation, + pipedInstance: pipedInstance ?? this.pipedInstance, + themeMode: themeMode ?? this.themeMode, + audioSource: audioSource ?? this.audioSource, + streamMusicCodec: streamMusicCodec ?? this.streamMusicCodec, + downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, + discordPresence: discordPresence ?? this.discordPresence, + endlessPlayback: endlessPlayback ?? this.endlessPlayback, + enableConnect: enableConnect ?? this.enableConnect, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (audioQuality.present) { + map['audio_quality'] = Variable($PreferencesTableTable + .$converteraudioQuality + .toSql(audioQuality.value)); + } + if (albumColorSync.present) { + map['album_color_sync'] = Variable(albumColorSync.value); + } + if (amoledDarkTheme.present) { + map['amoled_dark_theme'] = Variable(amoledDarkTheme.value); + } + if (checkUpdate.present) { + map['check_update'] = Variable(checkUpdate.value); + } + if (normalizeAudio.present) { + map['normalize_audio'] = Variable(normalizeAudio.value); + } + if (showSystemTrayIcon.present) { + map['show_system_tray_icon'] = Variable(showSystemTrayIcon.value); + } + if (systemTitleBar.present) { + map['system_title_bar'] = Variable(systemTitleBar.value); + } + if (skipNonMusic.present) { + map['skip_non_music'] = Variable(skipNonMusic.value); + } + if (closeBehavior.present) { + map['close_behavior'] = Variable($PreferencesTableTable + .$convertercloseBehavior + .toSql(closeBehavior.value)); + } + if (accentColorScheme.present) { + map['accent_color_scheme'] = Variable($PreferencesTableTable + .$converteraccentColorScheme + .toSql(accentColorScheme.value)); + } + if (layoutMode.present) { + map['layout_mode'] = Variable( + $PreferencesTableTable.$converterlayoutMode.toSql(layoutMode.value)); + } + if (locale.present) { + map['locale'] = Variable( + $PreferencesTableTable.$converterlocale.toSql(locale.value)); + } + if (market.present) { + map['market'] = Variable( + $PreferencesTableTable.$convertermarket.toSql(market.value)); + } + if (searchMode.present) { + map['search_mode'] = Variable( + $PreferencesTableTable.$convertersearchMode.toSql(searchMode.value)); + } + if (downloadLocation.present) { + map['download_location'] = Variable(downloadLocation.value); + } + if (localLibraryLocation.present) { + map['local_library_location'] = Variable($PreferencesTableTable + .$converterlocalLibraryLocation + .toSql(localLibraryLocation.value)); + } + if (pipedInstance.present) { + map['piped_instance'] = Variable(pipedInstance.value); + } + if (themeMode.present) { + map['theme_mode'] = Variable( + $PreferencesTableTable.$converterthemeMode.toSql(themeMode.value)); + } + if (audioSource.present) { + map['audio_source'] = Variable($PreferencesTableTable + .$converteraudioSource + .toSql(audioSource.value)); + } + if (streamMusicCodec.present) { + map['stream_music_codec'] = Variable($PreferencesTableTable + .$converterstreamMusicCodec + .toSql(streamMusicCodec.value)); + } + if (downloadMusicCodec.present) { + map['download_music_codec'] = Variable($PreferencesTableTable + .$converterdownloadMusicCodec + .toSql(downloadMusicCodec.value)); + } + if (discordPresence.present) { + map['discord_presence'] = Variable(discordPresence.value); + } + if (endlessPlayback.present) { + map['endless_playback'] = Variable(endlessPlayback.value); + } + if (enableConnect.present) { + map['enable_connect'] = Variable(enableConnect.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PreferencesTableCompanion(') + ..write('id: $id, ') + ..write('audioQuality: $audioQuality, ') + ..write('albumColorSync: $albumColorSync, ') + ..write('amoledDarkTheme: $amoledDarkTheme, ') + ..write('checkUpdate: $checkUpdate, ') + ..write('normalizeAudio: $normalizeAudio, ') + ..write('showSystemTrayIcon: $showSystemTrayIcon, ') + ..write('systemTitleBar: $systemTitleBar, ') + ..write('skipNonMusic: $skipNonMusic, ') + ..write('closeBehavior: $closeBehavior, ') + ..write('accentColorScheme: $accentColorScheme, ') + ..write('layoutMode: $layoutMode, ') + ..write('locale: $locale, ') + ..write('market: $market, ') + ..write('searchMode: $searchMode, ') + ..write('downloadLocation: $downloadLocation, ') + ..write('localLibraryLocation: $localLibraryLocation, ') + ..write('pipedInstance: $pipedInstance, ') + ..write('themeMode: $themeMode, ') + ..write('audioSource: $audioSource, ') + ..write('streamMusicCodec: $streamMusicCodec, ') + ..write('downloadMusicCodec: $downloadMusicCodec, ') + ..write('discordPresence: $discordPresence, ') + ..write('endlessPlayback: $endlessPlayback, ') + ..write('enableConnect: $enableConnect') + ..write(')')) + .toString(); + } +} + +abstract class _$AppDatabase extends GeneratedDatabase { + _$AppDatabase(QueryExecutor e) : super(e); + _$AppDatabaseManager get managers => _$AppDatabaseManager(this); + late final $PreferencesTableTable preferencesTable = + $PreferencesTableTable(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [preferencesTable]; +} + +typedef $$PreferencesTableTableInsertCompanionBuilder + = PreferencesTableCompanion Function({ + Value id, + Value audioQuality, + Value albumColorSync, + Value amoledDarkTheme, + Value checkUpdate, + Value normalizeAudio, + Value showSystemTrayIcon, + Value systemTitleBar, + Value skipNonMusic, + Value closeBehavior, + Value accentColorScheme, + Value layoutMode, + Value locale, + Value market, + Value searchMode, + Value downloadLocation, + Value> localLibraryLocation, + Value pipedInstance, + Value themeMode, + Value audioSource, + Value streamMusicCodec, + Value downloadMusicCodec, + Value discordPresence, + Value endlessPlayback, + Value enableConnect, +}); +typedef $$PreferencesTableTableUpdateCompanionBuilder + = PreferencesTableCompanion Function({ + Value id, + Value audioQuality, + Value albumColorSync, + Value amoledDarkTheme, + Value checkUpdate, + Value normalizeAudio, + Value showSystemTrayIcon, + Value systemTitleBar, + Value skipNonMusic, + Value closeBehavior, + Value accentColorScheme, + Value layoutMode, + Value locale, + Value market, + Value searchMode, + Value downloadLocation, + Value> localLibraryLocation, + Value pipedInstance, + Value themeMode, + Value audioSource, + Value streamMusicCodec, + Value downloadMusicCodec, + Value discordPresence, + Value endlessPlayback, + Value enableConnect, +}); + +class $$PreferencesTableTableTableManager extends RootTableManager< + _$AppDatabase, + $PreferencesTableTable, + PreferencesTableData, + $$PreferencesTableTableFilterComposer, + $$PreferencesTableTableOrderingComposer, + $$PreferencesTableTableProcessedTableManager, + $$PreferencesTableTableInsertCompanionBuilder, + $$PreferencesTableTableUpdateCompanionBuilder> { + $$PreferencesTableTableTableManager( + _$AppDatabase db, $PreferencesTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$PreferencesTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$PreferencesTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$PreferencesTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value audioQuality = const Value.absent(), + Value albumColorSync = const Value.absent(), + Value amoledDarkTheme = const Value.absent(), + Value checkUpdate = const Value.absent(), + Value normalizeAudio = const Value.absent(), + Value showSystemTrayIcon = const Value.absent(), + Value systemTitleBar = const Value.absent(), + Value skipNonMusic = const Value.absent(), + Value closeBehavior = const Value.absent(), + Value accentColorScheme = const Value.absent(), + Value layoutMode = const Value.absent(), + Value locale = const Value.absent(), + Value market = const Value.absent(), + Value searchMode = const Value.absent(), + Value downloadLocation = const Value.absent(), + Value> localLibraryLocation = const Value.absent(), + Value pipedInstance = const Value.absent(), + Value themeMode = const Value.absent(), + Value audioSource = const Value.absent(), + Value streamMusicCodec = const Value.absent(), + Value downloadMusicCodec = const Value.absent(), + Value discordPresence = const Value.absent(), + Value endlessPlayback = const Value.absent(), + Value enableConnect = const Value.absent(), + }) => + PreferencesTableCompanion( + id: id, + audioQuality: audioQuality, + albumColorSync: albumColorSync, + amoledDarkTheme: amoledDarkTheme, + checkUpdate: checkUpdate, + normalizeAudio: normalizeAudio, + showSystemTrayIcon: showSystemTrayIcon, + systemTitleBar: systemTitleBar, + skipNonMusic: skipNonMusic, + closeBehavior: closeBehavior, + accentColorScheme: accentColorScheme, + layoutMode: layoutMode, + locale: locale, + market: market, + searchMode: searchMode, + downloadLocation: downloadLocation, + localLibraryLocation: localLibraryLocation, + pipedInstance: pipedInstance, + themeMode: themeMode, + audioSource: audioSource, + streamMusicCodec: streamMusicCodec, + downloadMusicCodec: downloadMusicCodec, + discordPresence: discordPresence, + endlessPlayback: endlessPlayback, + enableConnect: enableConnect, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + Value audioQuality = const Value.absent(), + Value albumColorSync = const Value.absent(), + Value amoledDarkTheme = const Value.absent(), + Value checkUpdate = const Value.absent(), + Value normalizeAudio = const Value.absent(), + Value showSystemTrayIcon = const Value.absent(), + Value systemTitleBar = const Value.absent(), + Value skipNonMusic = const Value.absent(), + Value closeBehavior = const Value.absent(), + Value accentColorScheme = const Value.absent(), + Value layoutMode = const Value.absent(), + Value locale = const Value.absent(), + Value market = const Value.absent(), + Value searchMode = const Value.absent(), + Value downloadLocation = const Value.absent(), + Value> localLibraryLocation = const Value.absent(), + Value pipedInstance = const Value.absent(), + Value themeMode = const Value.absent(), + Value audioSource = const Value.absent(), + Value streamMusicCodec = const Value.absent(), + Value downloadMusicCodec = const Value.absent(), + Value discordPresence = const Value.absent(), + Value endlessPlayback = const Value.absent(), + Value enableConnect = const Value.absent(), + }) => + PreferencesTableCompanion.insert( + id: id, + audioQuality: audioQuality, + albumColorSync: albumColorSync, + amoledDarkTheme: amoledDarkTheme, + checkUpdate: checkUpdate, + normalizeAudio: normalizeAudio, + showSystemTrayIcon: showSystemTrayIcon, + systemTitleBar: systemTitleBar, + skipNonMusic: skipNonMusic, + closeBehavior: closeBehavior, + accentColorScheme: accentColorScheme, + layoutMode: layoutMode, + locale: locale, + market: market, + searchMode: searchMode, + downloadLocation: downloadLocation, + localLibraryLocation: localLibraryLocation, + pipedInstance: pipedInstance, + themeMode: themeMode, + audioSource: audioSource, + streamMusicCodec: streamMusicCodec, + downloadMusicCodec: downloadMusicCodec, + discordPresence: discordPresence, + endlessPlayback: endlessPlayback, + enableConnect: enableConnect, + ), + )); +} + +class $$PreferencesTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $PreferencesTableTable, + PreferencesTableData, + $$PreferencesTableTableFilterComposer, + $$PreferencesTableTableOrderingComposer, + $$PreferencesTableTableProcessedTableManager, + $$PreferencesTableTableInsertCompanionBuilder, + $$PreferencesTableTableUpdateCompanionBuilder> { + $$PreferencesTableTableProcessedTableManager(super.$state); +} + +class $$PreferencesTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $PreferencesTableTable> { + $$PreferencesTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get audioQuality => $state.composableBuilder( + column: $state.table.audioQuality, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get albumColorSync => $state.composableBuilder( + column: $state.table.albumColorSync, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get amoledDarkTheme => $state.composableBuilder( + column: $state.table.amoledDarkTheme, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get checkUpdate => $state.composableBuilder( + column: $state.table.checkUpdate, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get normalizeAudio => $state.composableBuilder( + column: $state.table.normalizeAudio, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get showSystemTrayIcon => $state.composableBuilder( + column: $state.table.showSystemTrayIcon, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get systemTitleBar => $state.composableBuilder( + column: $state.table.systemTitleBar, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get skipNonMusic => $state.composableBuilder( + column: $state.table.skipNonMusic, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get closeBehavior => $state.composableBuilder( + column: $state.table.closeBehavior, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get accentColorScheme => $state.composableBuilder( + column: $state.table.accentColorScheme, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get layoutMode => $state.composableBuilder( + column: $state.table.layoutMode, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters get locale => + $state.composableBuilder( + column: $state.table.locale, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters get market => + $state.composableBuilder( + column: $state.table.market, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get searchMode => $state.composableBuilder( + column: $state.table.searchMode, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get downloadLocation => $state.composableBuilder( + column: $state.table.downloadLocation, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters, List, String> + get localLibraryLocation => $state.composableBuilder( + column: $state.table.localLibraryLocation, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get pipedInstance => $state.composableBuilder( + column: $state.table.pipedInstance, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters get themeMode => + $state.composableBuilder( + column: $state.table.themeMode, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get audioSource => $state.composableBuilder( + column: $state.table.audioSource, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get streamMusicCodec => $state.composableBuilder( + column: $state.table.streamMusicCodec, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get downloadMusicCodec => $state.composableBuilder( + column: $state.table.downloadMusicCodec, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get discordPresence => $state.composableBuilder( + column: $state.table.discordPresence, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get endlessPlayback => $state.composableBuilder( + column: $state.table.endlessPlayback, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get enableConnect => $state.composableBuilder( + column: $state.table.enableConnect, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$PreferencesTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $PreferencesTableTable> { + $$PreferencesTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get audioQuality => $state.composableBuilder( + column: $state.table.audioQuality, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get albumColorSync => $state.composableBuilder( + column: $state.table.albumColorSync, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get amoledDarkTheme => $state.composableBuilder( + column: $state.table.amoledDarkTheme, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get checkUpdate => $state.composableBuilder( + column: $state.table.checkUpdate, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get normalizeAudio => $state.composableBuilder( + column: $state.table.normalizeAudio, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get showSystemTrayIcon => $state.composableBuilder( + column: $state.table.showSystemTrayIcon, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get systemTitleBar => $state.composableBuilder( + column: $state.table.systemTitleBar, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get skipNonMusic => $state.composableBuilder( + column: $state.table.skipNonMusic, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get closeBehavior => $state.composableBuilder( + column: $state.table.closeBehavior, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get accentColorScheme => $state.composableBuilder( + column: $state.table.accentColorScheme, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get layoutMode => $state.composableBuilder( + column: $state.table.layoutMode, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get locale => $state.composableBuilder( + column: $state.table.locale, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get market => $state.composableBuilder( + column: $state.table.market, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get searchMode => $state.composableBuilder( + column: $state.table.searchMode, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get downloadLocation => $state.composableBuilder( + column: $state.table.downloadLocation, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get localLibraryLocation => $state.composableBuilder( + column: $state.table.localLibraryLocation, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get pipedInstance => $state.composableBuilder( + column: $state.table.pipedInstance, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get themeMode => $state.composableBuilder( + column: $state.table.themeMode, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get audioSource => $state.composableBuilder( + column: $state.table.audioSource, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get streamMusicCodec => $state.composableBuilder( + column: $state.table.streamMusicCodec, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get downloadMusicCodec => $state.composableBuilder( + column: $state.table.downloadMusicCodec, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get discordPresence => $state.composableBuilder( + column: $state.table.discordPresence, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get endlessPlayback => $state.composableBuilder( + column: $state.table.endlessPlayback, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get enableConnect => $state.composableBuilder( + column: $state.table.enableConnect, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +class _$AppDatabaseManager { + final _$AppDatabase _db; + _$AppDatabaseManager(this._db); + $$PreferencesTableTableTableManager get preferencesTable => + $$PreferencesTableTableTableManager(_db, _db.preferencesTable); +} diff --git a/lib/models/database/tables/preferences.dart b/lib/models/database/tables/preferences.dart new file mode 100644 index 00000000..ae4ec1e8 --- /dev/null +++ b/lib/models/database/tables/preferences.dart @@ -0,0 +1,125 @@ +part of '../database.dart'; + +enum LayoutMode { + compact, + extended, + adaptive, +} + +enum CloseBehavior { + minimizeToTray, + close, +} + +enum AudioSource { + youtube, + piped, + jiosaavn; + + String get label => name[0].toUpperCase() + name.substring(1); +} + +enum MusicCodec { + m4a._("M4a (Best for downloaded music)"), + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + + final String label; + const MusicCodec._(this.label); +} + +enum SearchMode { + youtube._("YouTube"), + youtubeMusic._("YouTube Music"); + + final String label; + + const SearchMode._(this.label); + + factory SearchMode.fromString(String key) { + return SearchMode.values.firstWhere((e) => e.name == key); + } +} + +class PreferencesTable extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get audioQuality => textEnum() + .withDefault(Constant(SourceQualities.high.name))(); + BoolColumn get albumColorSync => + boolean().withDefault(const Constant(true))(); + BoolColumn get amoledDarkTheme => + boolean().withDefault(const Constant(false))(); + BoolColumn get checkUpdate => boolean().withDefault(const Constant(true))(); + BoolColumn get normalizeAudio => + boolean().withDefault(const Constant(false))(); + BoolColumn get showSystemTrayIcon => + boolean().withDefault(const Constant(false))(); + BoolColumn get systemTitleBar => + boolean().withDefault(const Constant(false))(); + BoolColumn get skipNonMusic => boolean().withDefault(const Constant(false))(); + TextColumn get closeBehavior => textEnum() + .withDefault(Constant(CloseBehavior.close.name))(); + TextColumn get accentColorScheme => text() + .withDefault(const Constant("Blue:0xFF2196F3")) + .map(const SpotubeColorConverter())(); + TextColumn get layoutMode => + textEnum().withDefault(Constant(LayoutMode.adaptive.name))(); + TextColumn get locale => text() + .withDefault( + const Constant('{"languageCode":"system","countryCode":"system"}'), + ) + .map(const LocaleConverter())(); + TextColumn get market => + textEnum().withDefault(Constant(Market.US.name))(); + TextColumn get searchMode => + textEnum().withDefault(Constant(SearchMode.youtube.name))(); + TextColumn get downloadLocation => text().withDefault(const Constant(""))(); + TextColumn get localLibraryLocation => + text().withDefault(const Constant("")).map(const StringListConverter())(); + TextColumn get pipedInstance => + text().withDefault(const Constant("https://pipedapi.kavin.rocks"))(); + TextColumn get themeMode => + textEnum().withDefault(Constant(ThemeMode.system.name))(); + TextColumn get audioSource => + textEnum().withDefault(Constant(AudioSource.youtube.name))(); + TextColumn get streamMusicCodec => + textEnum().withDefault(Constant(SourceCodecs.weba.name))(); + TextColumn get downloadMusicCodec => + textEnum().withDefault(Constant(SourceCodecs.m4a.name))(); + BoolColumn get discordPresence => + boolean().withDefault(const Constant(true))(); + BoolColumn get endlessPlayback => + boolean().withDefault(const Constant(true))(); + BoolColumn get enableConnect => + boolean().withDefault(const Constant(false))(); + + // Default values as PreferencesTableData + static PreferencesTableData defaults() { + return PreferencesTableData( + id: 0, + audioQuality: SourceQualities.high, + albumColorSync: true, + amoledDarkTheme: false, + checkUpdate: true, + normalizeAudio: false, + showSystemTrayIcon: false, + systemTitleBar: false, + skipNonMusic: false, + closeBehavior: CloseBehavior.close, + accentColorScheme: SpotubeColor(Colors.blue.value, name: "Blue"), + layoutMode: LayoutMode.adaptive, + locale: const Locale("system", "system"), + market: Market.US, + searchMode: SearchMode.youtube, + downloadLocation: "", + localLibraryLocation: [], + pipedInstance: "https://pipedapi.kavin.rocks", + themeMode: ThemeMode.system, + audioSource: AudioSource.youtube, + streamMusicCodec: SourceCodecs.weba, + downloadMusicCodec: SourceCodecs.m4a, + discordPresence: true, + endlessPlayback: true, + enableConnect: false, + ); + } +} diff --git a/lib/models/database/typeconverters/color.dart b/lib/models/database/typeconverters/color.dart new file mode 100644 index 00000000..70c27374 --- /dev/null +++ b/lib/models/database/typeconverters/color.dart @@ -0,0 +1,29 @@ +part of '../database.dart'; + +class ColorConverter extends TypeConverter { + const ColorConverter(); + + @override + Color fromSql(int fromDb) { + return Color(fromDb); + } + + @override + int toSql(Color value) { + return value.value; + } +} + +class SpotubeColorConverter extends TypeConverter { + const SpotubeColorConverter(); + + @override + SpotubeColor fromSql(String fromDb) { + return SpotubeColor.fromString(fromDb); + } + + @override + String toSql(SpotubeColor value) { + return value.toString(); + } +} diff --git a/lib/models/database/typeconverters/locale.dart b/lib/models/database/typeconverters/locale.dart new file mode 100644 index 00000000..c460088e --- /dev/null +++ b/lib/models/database/typeconverters/locale.dart @@ -0,0 +1,19 @@ +part of '../database.dart'; + +class LocaleConverter extends TypeConverter { + const LocaleConverter(); + + @override + Locale fromSql(String fromDb) { + final rawMap = jsonDecode(fromDb) as Map; + return Locale(rawMap["languageCode"], rawMap["countryCode"]); + } + + @override + String toSql(Locale value) { + return jsonEncode({ + "languageCode": value.languageCode, + "countryCode": value.countryCode, + }); + } +} diff --git a/lib/models/database/typeconverters/string_list.dart b/lib/models/database/typeconverters/string_list.dart new file mode 100644 index 00000000..5c30a997 --- /dev/null +++ b/lib/models/database/typeconverters/string_list.dart @@ -0,0 +1,15 @@ +part of '../database.dart'; + +class StringListConverter extends TypeConverter, String> { + const StringListConverter(); + + @override + List fromSql(String fromDb) { + return fromDb.split(","); + } + + @override + String toSql(List value) { + return value.join(","); + } +} diff --git a/lib/modules/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart index 14731907..a6136e62 100644 --- a/lib/modules/player/sibling_tracks_sheet.dart +++ b/lib/modules/player/sibling_tracks_sheet.dart @@ -14,10 +14,11 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/hooks/utils/use_debounce.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/models/video_info.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart index 2ab4b14a..14784176 100644 --- a/lib/modules/root/bottom_player.dart +++ b/lib/modules/root/bottom_player.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/player/player_actions.dart'; import 'package:spotube/modules/player/player_overlay.dart'; import 'package:spotube/modules/player/player_track_details.dart'; @@ -20,7 +21,7 @@ import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; diff --git a/lib/modules/root/sidebar.dart b/lib/modules/root/sidebar.dart index 79d229ef..592a3d90 100644 --- a/lib/modules/root/sidebar.dart +++ b/lib/modules/root/sidebar.dart @@ -9,6 +9,7 @@ import 'package:sidebarx/sidebarx.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/connect/connect_device.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; @@ -23,7 +24,7 @@ import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:window_manager/window_manager.dart'; diff --git a/lib/modules/root/spotube_navigation_bar.dart b/lib/modules/root/spotube_navigation_bar.dart index e16ad1a8..c624a40c 100644 --- a/lib/modules/root/spotube_navigation_bar.dart +++ b/lib/modules/root/spotube_navigation_bar.dart @@ -10,9 +10,10 @@ import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/utils/service_utils.dart'; final navigationPanelHeight = StateProvider((ref) => 50); diff --git a/lib/pages/getting_started/sections/playback.dart b/lib/pages/getting_started/sections/playback.dart index fab51d06..e7087afd 100644 --- a/lib/pages/getting_started/sections/playback.dart +++ b/lib/pages/getting_started/sections/playback.dart @@ -4,11 +4,11 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/string.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; final audioSourceToIconMap = { AudioSource.youtube: const Icon( diff --git a/lib/pages/getting_started/sections/region.dart b/lib/pages/getting_started/sections/region.dart index 0a80fba2..9e31a273 100644 --- a/lib/pages/getting_started/sections/region.dart +++ b/lib/pages/getting_started/sections/region.dart @@ -55,14 +55,14 @@ class GettingStartedPageLanguageRegionSection extends HookConsumerWidget { ), const Gap(16), DropdownMenu( - initialSelection: preferences.recommendationMarket, + initialSelection: preferences.market, onSelected: (value) { if (value == null) return; ref .read(userPreferencesProvider.notifier) .setRecommendationMarket(value); }, - hintText: preferences.recommendationMarket.name, + hintText: preferences.market.name, label: Text(context.l10n.market_place_region), inputDecorationTheme: const InputDecorationTheme(isDense: true), diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index c73c0b08..b62013c5 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -39,7 +39,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final genresCollection = ref.watch(categoryGenresProvider); final limit = useValueNotifier(10); - final market = useValueNotifier(preferences.recommendationMarket); + final market = useValueNotifier(preferences.market); final genres = useState>([]); final artists = useState>([]); diff --git a/lib/pages/settings/sections/appearance.dart b/lib/pages/settings/sections/appearance.dart index 67ed282b..f97add42 100644 --- a/lib/pages/settings/sections/appearance.dart +++ b/lib/pages/settings/sections/appearance.dart @@ -3,12 +3,12 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; class SettingsAppearanceSection extends HookConsumerWidget { final bool isGettingStarted; diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index 5fbbd8b0..88f0ae6d 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -2,11 +2,12 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/utils/platform.dart'; class SettingsDesktopSection extends HookConsumerWidget { diff --git a/lib/pages/settings/sections/language_region.dart b/lib/pages/settings/sections/language_region.dart index c9776fd6..18c2d088 100644 --- a/lib/pages/settings/sections/language_region.dart +++ b/lib/pages/settings/sections/language_region.dart @@ -57,7 +57,7 @@ class SettingsLanguageRegionSection extends HookConsumerWidget { secondary: const Icon(SpotubeIcons.shoppingBag), title: Text(context.l10n.market_place_region), subtitle: Text(context.l10n.recommendation_country), - value: preferences.recommendationMarket, + value: preferences.market, onChanged: (value) { if (value == null) return; preferencesNotifier.setRecommendationMarket(value); diff --git a/lib/pages/settings/sections/playback.dart b/lib/pages/settings/sections/playback.dart index 0d37d990..6273c557 100644 --- a/lib/pages/settings/sections/playback.dart +++ b/lib/pages/settings/sections/playback.dart @@ -6,12 +6,13 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/piped_instances_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/services/sourced_track/enums.dart'; class SettingsPlaybackSection extends HookConsumerWidget { diff --git a/lib/provider/database/database.dart b/lib/provider/database/database.dart new file mode 100644 index 00000000..95976e56 --- /dev/null +++ b/lib/provider/database/database.dart @@ -0,0 +1,4 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/models/database/database.dart'; + +final databaseProvider = Provider((ref) => AppDatabase()); diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index c8eb3657..d52073da 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -14,7 +14,7 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_services/audio_services.dart'; import 'package:spotube/provider/discord_provider.dart'; diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart index 12d066ac..461ac24e 100644 --- a/lib/provider/proxy_playlist/skip_segments.dart +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -1,10 +1,11 @@ +import 'package:spotube/models/database/database.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:dio/dio.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/services/dio/dio.dart'; class SourcedSegments { diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index dd9d6c3b..679f58b1 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -7,7 +7,6 @@ import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; import 'package:spotube/services/logger/logger.dart'; class ServerPlaybackRoutes { diff --git a/lib/provider/spotify/album/releases.dart b/lib/provider/spotify/album/releases.dart index cacddbdf..43d2e474 100644 --- a/lib/provider/spotify/album/releases.dart +++ b/lib/provider/spotify/album/releases.dart @@ -30,7 +30,7 @@ class AlbumReleasesNotifier @override fetch(int offset, int limit) async { - final market = ref.read(userPreferencesProvider).recommendationMarket; + final market = ref.read(userPreferencesProvider).market; final albums = await spotify.browse .newReleases(country: market) @@ -43,7 +43,7 @@ class AlbumReleasesNotifier build() async { ref.watch(spotifyProvider); ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), + userPreferencesProvider.select((s) => s.market), ); ref.watch(allFollowedArtistsProvider); diff --git a/lib/provider/spotify/artist/albums.dart b/lib/provider/spotify/artist/albums.dart index 16bd8768..32aa38a6 100644 --- a/lib/provider/spotify/artist/albums.dart +++ b/lib/provider/spotify/artist/albums.dart @@ -30,7 +30,7 @@ class ArtistAlbumsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< @override fetch(arg, offset, limit) async { - final market = ref.read(userPreferencesProvider).recommendationMarket; + final market = ref.read(userPreferencesProvider).market; final albums = await spotify.artists .albums(arg, country: market) .getPage(limit, offset); @@ -44,7 +44,7 @@ class ArtistAlbumsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< ref.watch(spotifyProvider); ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), + userPreferencesProvider.select((s) => s.market), ); final albums = await fetch(arg, 0, 20); return ArtistAlbumsState( diff --git a/lib/provider/spotify/artist/top_tracks.dart b/lib/provider/spotify/artist/top_tracks.dart index fa40d646..a2862c3d 100644 --- a/lib/provider/spotify/artist/top_tracks.dart +++ b/lib/provider/spotify/artist/top_tracks.dart @@ -6,8 +6,7 @@ final artistTopTracksProvider = ref.cacheFor(); final spotify = ref.watch(spotifyProvider); - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + final market = ref.watch(userPreferencesProvider.select((s) => s.market)); final tracks = await spotify.artists.topTracks(artistId, market); return tracks.toList(); diff --git a/lib/provider/spotify/category/categories.dart b/lib/provider/spotify/category/categories.dart index 7652215c..6237b64c 100644 --- a/lib/provider/spotify/category/categories.dart +++ b/lib/provider/spotify/category/categories.dart @@ -3,8 +3,7 @@ part of '../spotify.dart'; final categoriesProvider = FutureProvider( (ref) async { final spotify = ref.watch(spotifyProvider); - final market = ref - .watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + final market = ref.watch(userPreferencesProvider.select((s) => s.market)); final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); final categories = await spotify.categories .list( diff --git a/lib/provider/spotify/category/playlists.dart b/lib/provider/spotify/category/playlists.dart index 979b7f31..18d4845f 100644 --- a/lib/provider/spotify/category/playlists.dart +++ b/lib/provider/spotify/category/playlists.dart @@ -33,7 +33,7 @@ class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< final preferences = ref.read(userPreferencesProvider); final playlists = await Pages( spotify, - "v1/browse/categories/$arg/playlists?country=${preferences.recommendationMarket.name}&locale=${preferences.locale}", + "v1/browse/categories/$arg/playlists?country=${preferences.market.name}&locale=${preferences.locale}", (json) => json == null ? null : PlaylistSimple.fromJson(json), 'playlists', (json) => PlaylistsFeatured.fromJson(json), @@ -48,7 +48,7 @@ class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< ref.watch(spotifyProvider); ref.watch(userPreferencesProvider.select((s) => s.locale)); - ref.watch(userPreferencesProvider.select((s) => s.recommendationMarket)); + ref.watch(userPreferencesProvider.select((s) => s.market)); final playlists = await fetch(arg, 0, 8); diff --git a/lib/provider/spotify/playlist/generate.dart b/lib/provider/spotify/playlist/generate.dart index 2e1196dd..0832003e 100644 --- a/lib/provider/spotify/playlist/generate.dart +++ b/lib/provider/spotify/playlist/generate.dart @@ -5,7 +5,7 @@ final generatePlaylistProvider = FutureProvider.autoDispose (ref, input) async { final spotify = ref.watch(spotifyProvider); final market = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), + userPreferencesProvider.select((s) => s.market), ); final recommendation = await spotify.recommendations diff --git a/lib/provider/spotify/search/search.dart b/lib/provider/spotify/search/search.dart index bd97f08b..dc00d913 100644 --- a/lib/provider/spotify/search/search.dart +++ b/lib/provider/spotify/search/search.dart @@ -42,7 +42,7 @@ class SearchNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier value.recommendationMarket), + userPreferencesProvider.select((value) => value.market), ); final results = await fetch(arg, 0, 10); diff --git a/lib/provider/spotify/views/home.dart b/lib/provider/spotify/views/home.dart index 810d110d..51586953 100644 --- a/lib/provider/spotify/views/home.dart +++ b/lib/provider/spotify/views/home.dart @@ -5,7 +5,7 @@ import 'package:spotube/provider/user_preferences/user_preferences_provider.dart final homeViewProvider = FutureProvider((ref) async { final country = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), + userPreferencesProvider.select((s) => s.market), ); final spTCookie = ref.watch( authenticationProvider.select((s) => s?.getCookie("sp_t")), diff --git a/lib/provider/spotify/views/home_section.dart b/lib/provider/spotify/views/home_section.dart index 1078fa72..04c4cbd6 100644 --- a/lib/provider/spotify/views/home_section.dart +++ b/lib/provider/spotify/views/home_section.dart @@ -8,7 +8,7 @@ final homeSectionViewProvider = FutureProvider.family( (ref, sectionUri) async { final country = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), + userPreferencesProvider.select((s) => s.market), ); final spTCookie = ref.watch( authenticationProvider.select((s) => s?.getCookie("sp_t")), diff --git a/lib/provider/spotify/views/view.dart b/lib/provider/spotify/views/view.dart index f1af998b..ff565feb 100644 --- a/lib/provider/spotify/views/view.dart +++ b/lib/provider/spotify/views/view.dart @@ -4,7 +4,7 @@ final viewProvider = FutureProvider.family, String>( (ref, viewName) async { final customSpotify = ref.watch(customSpotifyEndpointProvider); final market = ref.watch( - userPreferencesProvider.select((s) => s.recommendationMarket), + userPreferencesProvider.select((s) => s.market), ); final locale = ref.watch( userPreferencesProvider.select((s) => s.locale), diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index 5825104a..8b96305f 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -1,54 +1,116 @@ -import 'dart:async'; - +import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_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/sourced_track/enums.dart'; - -import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; -import 'package:path/path.dart' as path; import 'package:window_manager/window_manager.dart'; -class UserPreferencesNotifier extends PersistedStateNotifier { - final Ref ref; +typedef UserPreferences = PreferencesTableData; - UserPreferencesNotifier(this.ref) - : super(UserPreferences.withDefaults(), "preferences"); +class UserPreferencesNotifier extends Notifier { + @override + build() { + final db = ref.watch(databaseProvider); - void reset() { - state = UserPreferences.withDefaults(); + (db.select(db.preferencesTable)..where((tbl) => tbl.id.equals(0))) + .getSingleOrNull() + .then((result) async { + if (result == null) { + await db.into(db.preferencesTable).insert( + PreferencesTableCompanion.insert( + id: const Value(0), + downloadLocation: Value(await _getDefaultDownloadDirectory()), + ), + ); + } + + state = await (db.select(db.preferencesTable) + ..where((tbl) => tbl.id.equals(0))) + .getSingle(); + + final subscription = (db.select(db.preferencesTable) + ..where((tbl) => tbl.id.equals(0))) + .watchSingle() + .listen((event) async { + state = event; + + if (kIsDesktop) { + await windowManager.setTitleBarStyle( + state.systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, + ); + } + + await audioPlayer.setAudioNormalization(state.normalizeAudio); + }); + + ref.onDispose(() { + subscription.cancel(); + }); + }); + + return PreferencesTable.defaults(); + } + + Future _getDefaultDownloadDirectory() async { + if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; + + if (kIsMacOS) { + return join((await getLibraryDirectory()).path, "Caches"); + } + + return getDownloadsDirectory().then((dir) { + return join(dir!.path, "Spotube"); + }); + } + + Future setData(PreferencesTableCompanion data) async { + final db = ref.read(databaseProvider); + + final query = db.update(db.preferencesTable)..where((t) => t.id.equals(0)); + + await query.write(data); + } + + Future reset() async { + final db = ref.read(databaseProvider); + + final query = db.update(db.preferencesTable)..where((t) => t.id.equals(0)); + + await query.replace(PreferencesTableCompanion.insert()); } void setStreamMusicCodec(SourceCodecs codec) { - state = state.copyWith(streamMusicCodec: codec); + setData(PreferencesTableCompanion(streamMusicCodec: Value(codec))); } void setDownloadMusicCodec(SourceCodecs codec) { - state = state.copyWith(downloadMusicCodec: codec); + setData(PreferencesTableCompanion(downloadMusicCodec: Value(codec))); } void setThemeMode(ThemeMode mode) { - state = state.copyWith(themeMode: mode); + setData(PreferencesTableCompanion(themeMode: Value(mode))); } void setRecommendationMarket(Market country) { - state = state.copyWith(recommendationMarket: country); + setData(PreferencesTableCompanion(market: Value(country))); } void setAccentColorScheme(SpotubeColor color) { - state = state.copyWith(accentColorScheme: color); + setData(PreferencesTableCompanion(accentColorScheme: Value(color))); } void setAlbumColorSync(bool sync) { - state = state.copyWith(albumColorSync: sync); + setData(PreferencesTableCompanion(albumColorSync: Value(sync))); if (!sync) { ref.read(paletteProvider.notifier).state = null; @@ -58,126 +120,87 @@ class UserPreferencesNotifier extends PersistedStateNotifier { } void setCheckUpdate(bool check) { - state = state.copyWith(checkUpdate: check); + setData(PreferencesTableCompanion(checkUpdate: Value(check))); } void setAudioQuality(SourceQualities quality) { - state = state.copyWith(audioQuality: quality); + setData(PreferencesTableCompanion(audioQuality: Value(quality))); } void setDownloadLocation(String downloadDir) { if (downloadDir.isEmpty) return; - state = state.copyWith(downloadLocation: downloadDir); + setData(PreferencesTableCompanion(downloadLocation: Value(downloadDir))); } void setLocalLibraryLocation(List localLibraryDirs) { //if (localLibraryDir.isEmpty) return; - state = state.copyWith(localLibraryLocation: localLibraryDirs); + setData(PreferencesTableCompanion( + localLibraryLocation: Value(localLibraryDirs))); } void setLayoutMode(LayoutMode mode) { - state = state.copyWith(layoutMode: mode); + setData(PreferencesTableCompanion(layoutMode: Value(mode))); } void setCloseBehavior(CloseBehavior behavior) { - state = state.copyWith(closeBehavior: behavior); + setData(PreferencesTableCompanion(closeBehavior: Value(behavior))); } void setShowSystemTrayIcon(bool show) { - state = state.copyWith(showSystemTrayIcon: show); + setData(PreferencesTableCompanion(showSystemTrayIcon: Value(show))); } void setLocale(Locale locale) { - state = state.copyWith(locale: locale); + setData(PreferencesTableCompanion(locale: Value(locale))); } void setPipedInstance(String instance) { - state = state.copyWith(pipedInstance: instance); + setData(PreferencesTableCompanion(pipedInstance: Value(instance))); } void setSearchMode(SearchMode mode) { - state = state.copyWith(searchMode: mode); + setData(PreferencesTableCompanion(searchMode: Value(mode))); } void setSkipNonMusic(bool skip) { - state = state.copyWith(skipNonMusic: skip); + setData(PreferencesTableCompanion(skipNonMusic: Value(skip))); } void setAudioSource(AudioSource type) { - state = state.copyWith(audioSource: type); + setData(PreferencesTableCompanion(audioSource: Value(type))); } void setSystemTitleBar(bool isSystemTitleBar) { - state = state.copyWith(systemTitleBar: isSystemTitleBar); - if (kIsDesktop) { - windowManager.setTitleBarStyle( - isSystemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, - ); - } + setData( + PreferencesTableCompanion( + systemTitleBar: Value(isSystemTitleBar), + ), + ); } void setDiscordPresence(bool discordPresence) { - state = state.copyWith(discordPresence: discordPresence); + setData(PreferencesTableCompanion(discordPresence: Value(discordPresence))); } void setAmoledDarkTheme(bool isAmoled) { - state = state.copyWith(amoledDarkTheme: isAmoled); + setData(PreferencesTableCompanion(amoledDarkTheme: Value(isAmoled))); } void setNormalizeAudio(bool normalize) { - state = state.copyWith(normalizeAudio: normalize); + setData(PreferencesTableCompanion(normalizeAudio: Value(normalize))); audioPlayer.setAudioNormalization(normalize); } void setEndlessPlayback(bool endless) { - state = state.copyWith(endlessPlayback: endless); + setData(PreferencesTableCompanion(endlessPlayback: Value(endless))); } void setEnableConnect(bool enable) { - state = state.copyWith(enableConnect: enable); - } - - Future _getDefaultDownloadDirectory() async { - if (kIsAndroid) return "/storage/emulated/0/Download/Spotube"; - - if (kIsMacOS) { - return path.join((await getLibraryDirectory()).path, "Caches"); - } - - return getDownloadsDirectory().then((dir) { - return path.join(dir!.path, "Spotube"); - }); - } - - @override - FutureOr onInit() async { - if (state.downloadLocation.isEmpty) { - state = state.copyWith( - downloadLocation: await _getDefaultDownloadDirectory(), - ); - } - - if (kIsDesktop) { - await windowManager.setTitleBarStyle( - state.systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, - ); - } - - await audioPlayer.setAudioNormalization(state.normalizeAudio); - } - - @override - FutureOr fromJson(Map json) { - return UserPreferences.fromJson(json); - } - - @override - Map toJson() { - return state.toJson(); + setData(PreferencesTableCompanion(enableConnect: Value(enable))); } } final userPreferencesProvider = - StateNotifierProvider( - (ref) => UserPreferencesNotifier(ref), + NotifierProvider( + () => UserPreferencesNotifier(), ); diff --git a/lib/provider/user_preferences/user_preferences_state.dart b/lib/provider/user_preferences/user_preferences_state.dart deleted file mode 100644 index 73dd02e8..00000000 --- a/lib/provider/user_preferences/user_preferences_state.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; -import 'package:spotube/services/sourced_track/enums.dart'; - -part 'user_preferences_state.g.dart'; -part 'user_preferences_state.freezed.dart'; - -@JsonEnum() -enum LayoutMode { - compact, - extended, - adaptive, -} - -@JsonEnum() -enum CloseBehavior { - minimizeToTray, - close, -} - -@JsonEnum() -enum AudioSource { - youtube, - piped, - jiosaavn; - - String get label => name[0].toUpperCase() + name.substring(1); -} - -@JsonEnum() -enum MusicCodec { - m4a._("M4a (Best for downloaded music)"), - weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); - - final String label; - const MusicCodec._(this.label); -} - -@JsonEnum() -enum SearchMode { - youtube._("YouTube"), - youtubeMusic._("YouTube Music"); - - final String label; - - const SearchMode._(this.label); - - factory SearchMode.fromString(String key) { - return SearchMode.values.firstWhere((e) => e.name == key); - } -} - -@freezed -class UserPreferences with _$UserPreferences { - const factory UserPreferences({ - @Default(SourceQualities.high) SourceQualities audioQuality, - @Default(true) bool albumColorSync, - @Default(false) bool amoledDarkTheme, - @Default(true) bool checkUpdate, - @Default(false) bool normalizeAudio, - @Default(false) bool showSystemTrayIcon, - @Default(false) bool skipNonMusic, - @Default(false) bool systemTitleBar, - @Default(CloseBehavior.close) CloseBehavior closeBehavior, - @Default(SpotubeColor(0xFF2196F3, name: "Blue")) - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue, - ) - SpotubeColor accentColorScheme, - @Default(LayoutMode.adaptive) LayoutMode layoutMode, - @Default(Locale("system", "system")) - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue, - ) - Locale locale, - @Default(Market.US) Market recommendationMarket, - @Default(SearchMode.youtube) SearchMode searchMode, - @Default("") String downloadLocation, - @Default([]) List localLibraryLocation, - @Default("https://pipedapi.kavin.rocks") String pipedInstance, - @Default(ThemeMode.system) ThemeMode themeMode, - @Default(AudioSource.youtube) AudioSource audioSource, - @Default(SourceCodecs.weba) SourceCodecs streamMusicCodec, - @Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec, - @Default(true) bool discordPresence, - @Default(true) bool endlessPlayback, - @Default(false) bool enableConnect, - }) = _UserPreferences; - factory UserPreferences.fromJson(Map json) => - _$UserPreferencesFromJson(json); - - factory UserPreferences.withDefaults() => UserPreferences.fromJson({}); - - static SpotubeColor _accentColorSchemeFromJson(Map json) { - return SpotubeColor.fromString(json["color"]); - } - - static Map? _accentColorSchemeReadValue( - Map json, String key) { - if (json[key] is String) { - return {"color": json[key]}; - } - - return json[key] as Map?; - } - - static Map _accentColorSchemeToJson(SpotubeColor color) { - return {"color": color.toString()}; - } - - static Locale _localeFromJson(Map json) { - return Locale(json["languageCode"], json["countryCode"]); - } - - static Map _localeToJson(Locale locale) { - return { - "languageCode": locale.languageCode, - "countryCode": locale.countryCode, - }; - } - - static Map? _localeReadValue( - Map json, String key) { - if (json[key] is String) { - final map = jsonDecode(json[key]); - return { - "languageCode": map["lc"], - "countryCode": map["cc"], - }; - } - - return json[key] as Map?; - } -} diff --git a/lib/provider/user_preferences/user_preferences_state.freezed.dart b/lib/provider/user_preferences/user_preferences_state.freezed.dart deleted file mode 100644 index 89c7210a..00000000 --- a/lib/provider/user_preferences/user_preferences_state.freezed.dart +++ /dev/null @@ -1,751 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'user_preferences_state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -UserPreferences _$UserPreferencesFromJson(Map json) { - return _UserPreferences.fromJson(json); -} - -/// @nodoc -mixin _$UserPreferences { - SourceQualities get audioQuality => throw _privateConstructorUsedError; - bool get albumColorSync => throw _privateConstructorUsedError; - bool get amoledDarkTheme => throw _privateConstructorUsedError; - bool get checkUpdate => throw _privateConstructorUsedError; - bool get normalizeAudio => throw _privateConstructorUsedError; - bool get showSystemTrayIcon => throw _privateConstructorUsedError; - bool get skipNonMusic => throw _privateConstructorUsedError; - bool get systemTitleBar => throw _privateConstructorUsedError; - CloseBehavior get closeBehavior => throw _privateConstructorUsedError; - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - SpotubeColor get accentColorScheme => throw _privateConstructorUsedError; - LayoutMode get layoutMode => throw _privateConstructorUsedError; - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - Locale get locale => throw _privateConstructorUsedError; - Market get recommendationMarket => throw _privateConstructorUsedError; - SearchMode get searchMode => throw _privateConstructorUsedError; - String get downloadLocation => throw _privateConstructorUsedError; - List get localLibraryLocation => throw _privateConstructorUsedError; - String get pipedInstance => throw _privateConstructorUsedError; - ThemeMode get themeMode => throw _privateConstructorUsedError; - AudioSource get audioSource => throw _privateConstructorUsedError; - SourceCodecs get streamMusicCodec => throw _privateConstructorUsedError; - SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError; - bool get discordPresence => throw _privateConstructorUsedError; - bool get endlessPlayback => throw _privateConstructorUsedError; - bool get enableConnect => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $UserPreferencesCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $UserPreferencesCopyWith<$Res> { - factory $UserPreferencesCopyWith( - UserPreferences value, $Res Function(UserPreferences) then) = - _$UserPreferencesCopyWithImpl<$Res, UserPreferences>; - @useResult - $Res call( - {SourceQualities audioQuality, - bool albumColorSync, - bool amoledDarkTheme, - bool checkUpdate, - bool normalizeAudio, - bool showSystemTrayIcon, - bool skipNonMusic, - bool systemTitleBar, - CloseBehavior closeBehavior, - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - SpotubeColor accentColorScheme, - LayoutMode layoutMode, - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - Locale locale, - Market recommendationMarket, - SearchMode searchMode, - String downloadLocation, - List localLibraryLocation, - String pipedInstance, - ThemeMode themeMode, - AudioSource audioSource, - SourceCodecs streamMusicCodec, - SourceCodecs downloadMusicCodec, - bool discordPresence, - bool endlessPlayback, - bool enableConnect}); -} - -/// @nodoc -class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> - implements $UserPreferencesCopyWith<$Res> { - _$UserPreferencesCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? audioQuality = null, - Object? albumColorSync = null, - Object? amoledDarkTheme = null, - Object? checkUpdate = null, - Object? normalizeAudio = null, - Object? showSystemTrayIcon = null, - Object? skipNonMusic = null, - Object? systemTitleBar = null, - Object? closeBehavior = null, - Object? accentColorScheme = null, - Object? layoutMode = null, - Object? locale = null, - Object? recommendationMarket = null, - Object? searchMode = null, - Object? downloadLocation = null, - Object? localLibraryLocation = null, - Object? pipedInstance = null, - Object? themeMode = null, - Object? audioSource = null, - Object? streamMusicCodec = null, - Object? downloadMusicCodec = null, - Object? discordPresence = null, - Object? endlessPlayback = null, - Object? enableConnect = null, - }) { - return _then(_value.copyWith( - audioQuality: null == audioQuality - ? _value.audioQuality - : audioQuality // ignore: cast_nullable_to_non_nullable - as SourceQualities, - albumColorSync: null == albumColorSync - ? _value.albumColorSync - : albumColorSync // ignore: cast_nullable_to_non_nullable - as bool, - amoledDarkTheme: null == amoledDarkTheme - ? _value.amoledDarkTheme - : amoledDarkTheme // ignore: cast_nullable_to_non_nullable - as bool, - checkUpdate: null == checkUpdate - ? _value.checkUpdate - : checkUpdate // ignore: cast_nullable_to_non_nullable - as bool, - normalizeAudio: null == normalizeAudio - ? _value.normalizeAudio - : normalizeAudio // ignore: cast_nullable_to_non_nullable - as bool, - showSystemTrayIcon: null == showSystemTrayIcon - ? _value.showSystemTrayIcon - : showSystemTrayIcon // ignore: cast_nullable_to_non_nullable - as bool, - skipNonMusic: null == skipNonMusic - ? _value.skipNonMusic - : skipNonMusic // ignore: cast_nullable_to_non_nullable - as bool, - systemTitleBar: null == systemTitleBar - ? _value.systemTitleBar - : systemTitleBar // ignore: cast_nullable_to_non_nullable - as bool, - closeBehavior: null == closeBehavior - ? _value.closeBehavior - : closeBehavior // ignore: cast_nullable_to_non_nullable - as CloseBehavior, - accentColorScheme: null == accentColorScheme - ? _value.accentColorScheme - : accentColorScheme // ignore: cast_nullable_to_non_nullable - as SpotubeColor, - layoutMode: null == layoutMode - ? _value.layoutMode - : layoutMode // ignore: cast_nullable_to_non_nullable - as LayoutMode, - locale: null == locale - ? _value.locale - : locale // ignore: cast_nullable_to_non_nullable - as Locale, - recommendationMarket: null == recommendationMarket - ? _value.recommendationMarket - : recommendationMarket // ignore: cast_nullable_to_non_nullable - as Market, - searchMode: null == searchMode - ? _value.searchMode - : searchMode // ignore: cast_nullable_to_non_nullable - as SearchMode, - downloadLocation: null == downloadLocation - ? _value.downloadLocation - : downloadLocation // ignore: cast_nullable_to_non_nullable - as String, - localLibraryLocation: null == localLibraryLocation - ? _value.localLibraryLocation - : localLibraryLocation // ignore: cast_nullable_to_non_nullable - as List, - pipedInstance: null == pipedInstance - ? _value.pipedInstance - : pipedInstance // ignore: cast_nullable_to_non_nullable - as String, - themeMode: null == themeMode - ? _value.themeMode - : themeMode // ignore: cast_nullable_to_non_nullable - as ThemeMode, - audioSource: null == audioSource - ? _value.audioSource - : audioSource // ignore: cast_nullable_to_non_nullable - as AudioSource, - streamMusicCodec: null == streamMusicCodec - ? _value.streamMusicCodec - : streamMusicCodec // ignore: cast_nullable_to_non_nullable - as SourceCodecs, - downloadMusicCodec: null == downloadMusicCodec - ? _value.downloadMusicCodec - : downloadMusicCodec // ignore: cast_nullable_to_non_nullable - as SourceCodecs, - discordPresence: null == discordPresence - ? _value.discordPresence - : discordPresence // ignore: cast_nullable_to_non_nullable - as bool, - endlessPlayback: null == endlessPlayback - ? _value.endlessPlayback - : endlessPlayback // ignore: cast_nullable_to_non_nullable - as bool, - enableConnect: null == enableConnect - ? _value.enableConnect - : enableConnect // ignore: cast_nullable_to_non_nullable - as bool, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$UserPreferencesImplCopyWith<$Res> - implements $UserPreferencesCopyWith<$Res> { - factory _$$UserPreferencesImplCopyWith(_$UserPreferencesImpl value, - $Res Function(_$UserPreferencesImpl) then) = - __$$UserPreferencesImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {SourceQualities audioQuality, - bool albumColorSync, - bool amoledDarkTheme, - bool checkUpdate, - bool normalizeAudio, - bool showSystemTrayIcon, - bool skipNonMusic, - bool systemTitleBar, - CloseBehavior closeBehavior, - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - SpotubeColor accentColorScheme, - LayoutMode layoutMode, - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - Locale locale, - Market recommendationMarket, - SearchMode searchMode, - String downloadLocation, - List localLibraryLocation, - String pipedInstance, - ThemeMode themeMode, - AudioSource audioSource, - SourceCodecs streamMusicCodec, - SourceCodecs downloadMusicCodec, - bool discordPresence, - bool endlessPlayback, - bool enableConnect}); -} - -/// @nodoc -class __$$UserPreferencesImplCopyWithImpl<$Res> - extends _$UserPreferencesCopyWithImpl<$Res, _$UserPreferencesImpl> - implements _$$UserPreferencesImplCopyWith<$Res> { - __$$UserPreferencesImplCopyWithImpl( - _$UserPreferencesImpl _value, $Res Function(_$UserPreferencesImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? audioQuality = null, - Object? albumColorSync = null, - Object? amoledDarkTheme = null, - Object? checkUpdate = null, - Object? normalizeAudio = null, - Object? showSystemTrayIcon = null, - Object? skipNonMusic = null, - Object? systemTitleBar = null, - Object? closeBehavior = null, - Object? accentColorScheme = null, - Object? layoutMode = null, - Object? locale = null, - Object? recommendationMarket = null, - Object? searchMode = null, - Object? downloadLocation = null, - Object? localLibraryLocation = null, - Object? pipedInstance = null, - Object? themeMode = null, - Object? audioSource = null, - Object? streamMusicCodec = null, - Object? downloadMusicCodec = null, - Object? discordPresence = null, - Object? endlessPlayback = null, - Object? enableConnect = null, - }) { - return _then(_$UserPreferencesImpl( - audioQuality: null == audioQuality - ? _value.audioQuality - : audioQuality // ignore: cast_nullable_to_non_nullable - as SourceQualities, - albumColorSync: null == albumColorSync - ? _value.albumColorSync - : albumColorSync // ignore: cast_nullable_to_non_nullable - as bool, - amoledDarkTheme: null == amoledDarkTheme - ? _value.amoledDarkTheme - : amoledDarkTheme // ignore: cast_nullable_to_non_nullable - as bool, - checkUpdate: null == checkUpdate - ? _value.checkUpdate - : checkUpdate // ignore: cast_nullable_to_non_nullable - as bool, - normalizeAudio: null == normalizeAudio - ? _value.normalizeAudio - : normalizeAudio // ignore: cast_nullable_to_non_nullable - as bool, - showSystemTrayIcon: null == showSystemTrayIcon - ? _value.showSystemTrayIcon - : showSystemTrayIcon // ignore: cast_nullable_to_non_nullable - as bool, - skipNonMusic: null == skipNonMusic - ? _value.skipNonMusic - : skipNonMusic // ignore: cast_nullable_to_non_nullable - as bool, - systemTitleBar: null == systemTitleBar - ? _value.systemTitleBar - : systemTitleBar // ignore: cast_nullable_to_non_nullable - as bool, - closeBehavior: null == closeBehavior - ? _value.closeBehavior - : closeBehavior // ignore: cast_nullable_to_non_nullable - as CloseBehavior, - accentColorScheme: null == accentColorScheme - ? _value.accentColorScheme - : accentColorScheme // ignore: cast_nullable_to_non_nullable - as SpotubeColor, - layoutMode: null == layoutMode - ? _value.layoutMode - : layoutMode // ignore: cast_nullable_to_non_nullable - as LayoutMode, - locale: null == locale - ? _value.locale - : locale // ignore: cast_nullable_to_non_nullable - as Locale, - recommendationMarket: null == recommendationMarket - ? _value.recommendationMarket - : recommendationMarket // ignore: cast_nullable_to_non_nullable - as Market, - searchMode: null == searchMode - ? _value.searchMode - : searchMode // ignore: cast_nullable_to_non_nullable - as SearchMode, - downloadLocation: null == downloadLocation - ? _value.downloadLocation - : downloadLocation // ignore: cast_nullable_to_non_nullable - as String, - localLibraryLocation: null == localLibraryLocation - ? _value._localLibraryLocation - : localLibraryLocation // ignore: cast_nullable_to_non_nullable - as List, - pipedInstance: null == pipedInstance - ? _value.pipedInstance - : pipedInstance // ignore: cast_nullable_to_non_nullable - as String, - themeMode: null == themeMode - ? _value.themeMode - : themeMode // ignore: cast_nullable_to_non_nullable - as ThemeMode, - audioSource: null == audioSource - ? _value.audioSource - : audioSource // ignore: cast_nullable_to_non_nullable - as AudioSource, - streamMusicCodec: null == streamMusicCodec - ? _value.streamMusicCodec - : streamMusicCodec // ignore: cast_nullable_to_non_nullable - as SourceCodecs, - downloadMusicCodec: null == downloadMusicCodec - ? _value.downloadMusicCodec - : downloadMusicCodec // ignore: cast_nullable_to_non_nullable - as SourceCodecs, - discordPresence: null == discordPresence - ? _value.discordPresence - : discordPresence // ignore: cast_nullable_to_non_nullable - as bool, - endlessPlayback: null == endlessPlayback - ? _value.endlessPlayback - : endlessPlayback // ignore: cast_nullable_to_non_nullable - as bool, - enableConnect: null == enableConnect - ? _value.enableConnect - : enableConnect // ignore: cast_nullable_to_non_nullable - as bool, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$UserPreferencesImpl implements _UserPreferences { - const _$UserPreferencesImpl( - {this.audioQuality = SourceQualities.high, - this.albumColorSync = true, - this.amoledDarkTheme = false, - this.checkUpdate = true, - this.normalizeAudio = false, - this.showSystemTrayIcon = false, - this.skipNonMusic = false, - this.systemTitleBar = false, - this.closeBehavior = CloseBehavior.close, - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - this.accentColorScheme = const SpotubeColor(0xFF2196F3, name: "Blue"), - this.layoutMode = LayoutMode.adaptive, - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - this.locale = const Locale("system", "system"), - this.recommendationMarket = Market.US, - this.searchMode = SearchMode.youtube, - this.downloadLocation = "", - final List localLibraryLocation = const [], - this.pipedInstance = "https://pipedapi.kavin.rocks", - this.themeMode = ThemeMode.system, - this.audioSource = AudioSource.youtube, - this.streamMusicCodec = SourceCodecs.weba, - this.downloadMusicCodec = SourceCodecs.m4a, - this.discordPresence = true, - this.endlessPlayback = true, - this.enableConnect = false}) - : _localLibraryLocation = localLibraryLocation; - - factory _$UserPreferencesImpl.fromJson(Map json) => - _$$UserPreferencesImplFromJson(json); - - @override - @JsonKey() - final SourceQualities audioQuality; - @override - @JsonKey() - final bool albumColorSync; - @override - @JsonKey() - final bool amoledDarkTheme; - @override - @JsonKey() - final bool checkUpdate; - @override - @JsonKey() - final bool normalizeAudio; - @override - @JsonKey() - final bool showSystemTrayIcon; - @override - @JsonKey() - final bool skipNonMusic; - @override - @JsonKey() - final bool systemTitleBar; - @override - @JsonKey() - final CloseBehavior closeBehavior; - @override - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - final SpotubeColor accentColorScheme; - @override - @JsonKey() - final LayoutMode layoutMode; - @override - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - final Locale locale; - @override - @JsonKey() - final Market recommendationMarket; - @override - @JsonKey() - final SearchMode searchMode; - @override - @JsonKey() - final String downloadLocation; - final List _localLibraryLocation; - @override - @JsonKey() - List get localLibraryLocation { - if (_localLibraryLocation is EqualUnmodifiableListView) - return _localLibraryLocation; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_localLibraryLocation); - } - - @override - @JsonKey() - final String pipedInstance; - @override - @JsonKey() - final ThemeMode themeMode; - @override - @JsonKey() - final AudioSource audioSource; - @override - @JsonKey() - final SourceCodecs streamMusicCodec; - @override - @JsonKey() - final SourceCodecs downloadMusicCodec; - @override - @JsonKey() - final bool discordPresence; - @override - @JsonKey() - final bool endlessPlayback; - @override - @JsonKey() - final bool enableConnect; - - @override - String toString() { - return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, localLibraryLocation: $localLibraryLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$UserPreferencesImpl && - (identical(other.audioQuality, audioQuality) || - other.audioQuality == audioQuality) && - (identical(other.albumColorSync, albumColorSync) || - other.albumColorSync == albumColorSync) && - (identical(other.amoledDarkTheme, amoledDarkTheme) || - other.amoledDarkTheme == amoledDarkTheme) && - (identical(other.checkUpdate, checkUpdate) || - other.checkUpdate == checkUpdate) && - (identical(other.normalizeAudio, normalizeAudio) || - other.normalizeAudio == normalizeAudio) && - (identical(other.showSystemTrayIcon, showSystemTrayIcon) || - other.showSystemTrayIcon == showSystemTrayIcon) && - (identical(other.skipNonMusic, skipNonMusic) || - other.skipNonMusic == skipNonMusic) && - (identical(other.systemTitleBar, systemTitleBar) || - other.systemTitleBar == systemTitleBar) && - (identical(other.closeBehavior, closeBehavior) || - other.closeBehavior == closeBehavior) && - (identical(other.accentColorScheme, accentColorScheme) || - other.accentColorScheme == accentColorScheme) && - (identical(other.layoutMode, layoutMode) || - other.layoutMode == layoutMode) && - (identical(other.locale, locale) || other.locale == locale) && - (identical(other.recommendationMarket, recommendationMarket) || - other.recommendationMarket == recommendationMarket) && - (identical(other.searchMode, searchMode) || - other.searchMode == searchMode) && - (identical(other.downloadLocation, downloadLocation) || - other.downloadLocation == downloadLocation) && - const DeepCollectionEquality() - .equals(other._localLibraryLocation, _localLibraryLocation) && - (identical(other.pipedInstance, pipedInstance) || - other.pipedInstance == pipedInstance) && - (identical(other.themeMode, themeMode) || - other.themeMode == themeMode) && - (identical(other.audioSource, audioSource) || - other.audioSource == audioSource) && - (identical(other.streamMusicCodec, streamMusicCodec) || - other.streamMusicCodec == streamMusicCodec) && - (identical(other.downloadMusicCodec, downloadMusicCodec) || - other.downloadMusicCodec == downloadMusicCodec) && - (identical(other.discordPresence, discordPresence) || - other.discordPresence == discordPresence) && - (identical(other.endlessPlayback, endlessPlayback) || - other.endlessPlayback == endlessPlayback) && - (identical(other.enableConnect, enableConnect) || - other.enableConnect == enableConnect)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hashAll([ - runtimeType, - audioQuality, - albumColorSync, - amoledDarkTheme, - checkUpdate, - normalizeAudio, - showSystemTrayIcon, - skipNonMusic, - systemTitleBar, - closeBehavior, - accentColorScheme, - layoutMode, - locale, - recommendationMarket, - searchMode, - downloadLocation, - const DeepCollectionEquality().hash(_localLibraryLocation), - pipedInstance, - themeMode, - audioSource, - streamMusicCodec, - downloadMusicCodec, - discordPresence, - endlessPlayback, - enableConnect - ]); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => - __$$UserPreferencesImplCopyWithImpl<_$UserPreferencesImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$UserPreferencesImplToJson( - this, - ); - } -} - -abstract class _UserPreferences implements UserPreferences { - const factory _UserPreferences( - {final SourceQualities audioQuality, - final bool albumColorSync, - final bool amoledDarkTheme, - final bool checkUpdate, - final bool normalizeAudio, - final bool showSystemTrayIcon, - final bool skipNonMusic, - final bool systemTitleBar, - final CloseBehavior closeBehavior, - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - final SpotubeColor accentColorScheme, - final LayoutMode layoutMode, - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - final Locale locale, - final Market recommendationMarket, - final SearchMode searchMode, - final String downloadLocation, - final List localLibraryLocation, - final String pipedInstance, - final ThemeMode themeMode, - final AudioSource audioSource, - final SourceCodecs streamMusicCodec, - final SourceCodecs downloadMusicCodec, - final bool discordPresence, - final bool endlessPlayback, - final bool enableConnect}) = _$UserPreferencesImpl; - - factory _UserPreferences.fromJson(Map json) = - _$UserPreferencesImpl.fromJson; - - @override - SourceQualities get audioQuality; - @override - bool get albumColorSync; - @override - bool get amoledDarkTheme; - @override - bool get checkUpdate; - @override - bool get normalizeAudio; - @override - bool get showSystemTrayIcon; - @override - bool get skipNonMusic; - @override - bool get systemTitleBar; - @override - CloseBehavior get closeBehavior; - @override - @JsonKey( - fromJson: UserPreferences._accentColorSchemeFromJson, - toJson: UserPreferences._accentColorSchemeToJson, - readValue: UserPreferences._accentColorSchemeReadValue) - SpotubeColor get accentColorScheme; - @override - LayoutMode get layoutMode; - @override - @JsonKey( - fromJson: UserPreferences._localeFromJson, - toJson: UserPreferences._localeToJson, - readValue: UserPreferences._localeReadValue) - Locale get locale; - @override - Market get recommendationMarket; - @override - SearchMode get searchMode; - @override - String get downloadLocation; - @override - List get localLibraryLocation; - @override - String get pipedInstance; - @override - ThemeMode get themeMode; - @override - AudioSource get audioSource; - @override - SourceCodecs get streamMusicCodec; - @override - SourceCodecs get downloadMusicCodec; - @override - bool get discordPresence; - @override - bool get endlessPlayback; - @override - bool get enableConnect; - @override - @JsonKey(ignore: true) - _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/provider/user_preferences/user_preferences_state.g.dart b/lib/provider/user_preferences/user_preferences_state.g.dart deleted file mode 100644 index 4bcb3a46..00000000 --- a/lib/provider/user_preferences/user_preferences_state.g.dart +++ /dev/null @@ -1,388 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'user_preferences_state.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$UserPreferencesImpl _$$UserPreferencesImplFromJson(Map json) => - _$UserPreferencesImpl( - audioQuality: - $enumDecodeNullable(_$SourceQualitiesEnumMap, json['audioQuality']) ?? - SourceQualities.high, - albumColorSync: json['albumColorSync'] as bool? ?? true, - amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, - checkUpdate: json['checkUpdate'] as bool? ?? true, - normalizeAudio: json['normalizeAudio'] as bool? ?? false, - showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? false, - skipNonMusic: json['skipNonMusic'] as bool? ?? false, - systemTitleBar: json['systemTitleBar'] as bool? ?? false, - closeBehavior: - $enumDecodeNullable(_$CloseBehaviorEnumMap, json['closeBehavior']) ?? - CloseBehavior.close, - accentColorScheme: UserPreferences._accentColorSchemeReadValue( - json, 'accentColorScheme') == - null - ? const SpotubeColor(0xFF2196F3, name: "Blue") - : UserPreferences._accentColorSchemeFromJson( - UserPreferences._accentColorSchemeReadValue( - json, 'accentColorScheme') as Map), - layoutMode: - $enumDecodeNullable(_$LayoutModeEnumMap, json['layoutMode']) ?? - LayoutMode.adaptive, - locale: UserPreferences._localeReadValue(json, 'locale') == null - ? const Locale("system", "system") - : UserPreferences._localeFromJson( - UserPreferences._localeReadValue(json, 'locale') - as Map), - recommendationMarket: - $enumDecodeNullable(_$MarketEnumMap, json['recommendationMarket']) ?? - Market.US, - searchMode: - $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? - SearchMode.youtube, - downloadLocation: json['downloadLocation'] as String? ?? "", - localLibraryLocation: (json['localLibraryLocation'] as List?) - ?.map((e) => e as String) - .toList() ?? - const [], - pipedInstance: - json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks", - themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? - ThemeMode.system, - audioSource: - $enumDecodeNullable(_$AudioSourceEnumMap, json['audioSource']) ?? - AudioSource.youtube, - streamMusicCodec: $enumDecodeNullable( - _$SourceCodecsEnumMap, json['streamMusicCodec']) ?? - SourceCodecs.weba, - downloadMusicCodec: $enumDecodeNullable( - _$SourceCodecsEnumMap, json['downloadMusicCodec']) ?? - SourceCodecs.m4a, - discordPresence: json['discordPresence'] as bool? ?? true, - endlessPlayback: json['endlessPlayback'] as bool? ?? true, - enableConnect: json['enableConnect'] as bool? ?? false, - ); - -Map _$$UserPreferencesImplToJson( - _$UserPreferencesImpl instance) => - { - 'audioQuality': _$SourceQualitiesEnumMap[instance.audioQuality]!, - 'albumColorSync': instance.albumColorSync, - 'amoledDarkTheme': instance.amoledDarkTheme, - 'checkUpdate': instance.checkUpdate, - 'normalizeAudio': instance.normalizeAudio, - 'showSystemTrayIcon': instance.showSystemTrayIcon, - 'skipNonMusic': instance.skipNonMusic, - 'systemTitleBar': instance.systemTitleBar, - 'closeBehavior': _$CloseBehaviorEnumMap[instance.closeBehavior]!, - 'accentColorScheme': - UserPreferences._accentColorSchemeToJson(instance.accentColorScheme), - 'layoutMode': _$LayoutModeEnumMap[instance.layoutMode]!, - 'locale': UserPreferences._localeToJson(instance.locale), - 'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!, - 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, - 'downloadLocation': instance.downloadLocation, - 'localLibraryLocation': instance.localLibraryLocation, - 'pipedInstance': instance.pipedInstance, - 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, - 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, - 'streamMusicCodec': _$SourceCodecsEnumMap[instance.streamMusicCodec]!, - 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, - 'discordPresence': instance.discordPresence, - 'endlessPlayback': instance.endlessPlayback, - 'enableConnect': instance.enableConnect, - }; - -const _$SourceQualitiesEnumMap = { - SourceQualities.high: 'high', - SourceQualities.medium: 'medium', - SourceQualities.low: 'low', -}; - -const _$CloseBehaviorEnumMap = { - CloseBehavior.minimizeToTray: 'minimizeToTray', - CloseBehavior.close: 'close', -}; - -const _$LayoutModeEnumMap = { - LayoutMode.compact: 'compact', - LayoutMode.extended: 'extended', - LayoutMode.adaptive: 'adaptive', -}; - -const _$MarketEnumMap = { - Market.AD: 'AD', - Market.AE: 'AE', - Market.AF: 'AF', - Market.AG: 'AG', - Market.AI: 'AI', - Market.AL: 'AL', - Market.AM: 'AM', - Market.AO: 'AO', - Market.AQ: 'AQ', - Market.AR: 'AR', - Market.AS: 'AS', - Market.AT: 'AT', - Market.AU: 'AU', - Market.AW: 'AW', - Market.AX: 'AX', - Market.AZ: 'AZ', - Market.BA: 'BA', - Market.BB: 'BB', - Market.BD: 'BD', - Market.BE: 'BE', - Market.BF: 'BF', - Market.BG: 'BG', - Market.BH: 'BH', - Market.BI: 'BI', - Market.BJ: 'BJ', - Market.BL: 'BL', - Market.BM: 'BM', - Market.BN: 'BN', - Market.BO: 'BO', - Market.BQ: 'BQ', - Market.BR: 'BR', - Market.BS: 'BS', - Market.BT: 'BT', - Market.BV: 'BV', - Market.BW: 'BW', - Market.BY: 'BY', - Market.BZ: 'BZ', - Market.CA: 'CA', - Market.CC: 'CC', - Market.CD: 'CD', - Market.CF: 'CF', - Market.CG: 'CG', - Market.CH: 'CH', - Market.CI: 'CI', - Market.CK: 'CK', - Market.CL: 'CL', - Market.CM: 'CM', - Market.CN: 'CN', - Market.CO: 'CO', - Market.CR: 'CR', - Market.CU: 'CU', - Market.CV: 'CV', - Market.CW: 'CW', - Market.CX: 'CX', - Market.CY: 'CY', - Market.CZ: 'CZ', - Market.DE: 'DE', - Market.DJ: 'DJ', - Market.DK: 'DK', - Market.DM: 'DM', - Market.DO: 'DO', - Market.DZ: 'DZ', - Market.EC: 'EC', - Market.EE: 'EE', - Market.EG: 'EG', - Market.EH: 'EH', - Market.ER: 'ER', - Market.ES: 'ES', - Market.ET: 'ET', - Market.FI: 'FI', - Market.FJ: 'FJ', - Market.FK: 'FK', - Market.FM: 'FM', - Market.FO: 'FO', - Market.FR: 'FR', - Market.GA: 'GA', - Market.GB: 'GB', - Market.GD: 'GD', - Market.GE: 'GE', - Market.GF: 'GF', - Market.GG: 'GG', - Market.GH: 'GH', - Market.GI: 'GI', - Market.GL: 'GL', - Market.GM: 'GM', - Market.GN: 'GN', - Market.GP: 'GP', - Market.GQ: 'GQ', - Market.GR: 'GR', - Market.GS: 'GS', - Market.GT: 'GT', - Market.GU: 'GU', - Market.GW: 'GW', - Market.GY: 'GY', - Market.HK: 'HK', - Market.HM: 'HM', - Market.HN: 'HN', - Market.HR: 'HR', - Market.HT: 'HT', - Market.HU: 'HU', - Market.ID: 'ID', - Market.IE: 'IE', - Market.IL: 'IL', - Market.IM: 'IM', - Market.IN: 'IN', - Market.IO: 'IO', - Market.IQ: 'IQ', - Market.IR: 'IR', - Market.IS: 'IS', - Market.IT: 'IT', - Market.JE: 'JE', - Market.JM: 'JM', - Market.JO: 'JO', - Market.JP: 'JP', - Market.KE: 'KE', - Market.KG: 'KG', - Market.KH: 'KH', - Market.KI: 'KI', - Market.KM: 'KM', - Market.KN: 'KN', - Market.KP: 'KP', - Market.KR: 'KR', - Market.KW: 'KW', - Market.KY: 'KY', - Market.KZ: 'KZ', - Market.LA: 'LA', - Market.LB: 'LB', - Market.LC: 'LC', - Market.LI: 'LI', - Market.LK: 'LK', - Market.LR: 'LR', - Market.LS: 'LS', - Market.LT: 'LT', - Market.LU: 'LU', - Market.LV: 'LV', - Market.LY: 'LY', - Market.MA: 'MA', - Market.MC: 'MC', - Market.MD: 'MD', - Market.ME: 'ME', - Market.MF: 'MF', - Market.MG: 'MG', - Market.MH: 'MH', - Market.MK: 'MK', - Market.ML: 'ML', - Market.MM: 'MM', - Market.MN: 'MN', - Market.MO: 'MO', - Market.MP: 'MP', - Market.MQ: 'MQ', - Market.MR: 'MR', - Market.MS: 'MS', - Market.MT: 'MT', - Market.MU: 'MU', - Market.MV: 'MV', - Market.MW: 'MW', - Market.MX: 'MX', - Market.MY: 'MY', - Market.MZ: 'MZ', - Market.NA: 'NA', - Market.NC: 'NC', - Market.NE: 'NE', - Market.NF: 'NF', - Market.NG: 'NG', - Market.NI: 'NI', - Market.NL: 'NL', - Market.NO: 'NO', - Market.NP: 'NP', - Market.NR: 'NR', - Market.NU: 'NU', - Market.NZ: 'NZ', - Market.OM: 'OM', - Market.PA: 'PA', - Market.PE: 'PE', - Market.PF: 'PF', - Market.PG: 'PG', - Market.PH: 'PH', - Market.PK: 'PK', - Market.PL: 'PL', - Market.PM: 'PM', - Market.PN: 'PN', - Market.PR: 'PR', - Market.PS: 'PS', - Market.PT: 'PT', - Market.PW: 'PW', - Market.PY: 'PY', - Market.QA: 'QA', - Market.RE: 'RE', - Market.RO: 'RO', - Market.RS: 'RS', - Market.RU: 'RU', - Market.RW: 'RW', - Market.SA: 'SA', - Market.SB: 'SB', - Market.SC: 'SC', - Market.SD: 'SD', - Market.SE: 'SE', - Market.SG: 'SG', - Market.SH: 'SH', - Market.SI: 'SI', - Market.SJ: 'SJ', - Market.SK: 'SK', - Market.SL: 'SL', - Market.SM: 'SM', - Market.SN: 'SN', - Market.SO: 'SO', - Market.SR: 'SR', - Market.SS: 'SS', - Market.ST: 'ST', - Market.SV: 'SV', - Market.SX: 'SX', - Market.SY: 'SY', - Market.SZ: 'SZ', - Market.TC: 'TC', - Market.TD: 'TD', - Market.TF: 'TF', - Market.TG: 'TG', - Market.TH: 'TH', - Market.TJ: 'TJ', - Market.TK: 'TK', - Market.TL: 'TL', - Market.TM: 'TM', - Market.TN: 'TN', - Market.TO: 'TO', - Market.TR: 'TR', - Market.TT: 'TT', - Market.TV: 'TV', - Market.TW: 'TW', - Market.TZ: 'TZ', - Market.UA: 'UA', - Market.UG: 'UG', - Market.UM: 'UM', - Market.US: 'US', - Market.UY: 'UY', - Market.UZ: 'UZ', - Market.VA: 'VA', - Market.VC: 'VC', - Market.VE: 'VE', - Market.VG: 'VG', - Market.VI: 'VI', - Market.VN: 'VN', - Market.VU: 'VU', - Market.WF: 'WF', - Market.WS: 'WS', - Market.XK: 'XK', - Market.YE: 'YE', - Market.YT: 'YT', - Market.ZA: 'ZA', - Market.ZM: 'ZM', - Market.ZW: 'ZW', -}; - -const _$SearchModeEnumMap = { - SearchMode.youtube: 'youtube', - SearchMode.youtubeMusic: 'youtubeMusic', -}; - -const _$ThemeModeEnumMap = { - ThemeMode.system: 'system', - ThemeMode.light: 'light', - ThemeMode.dark: 'dark', -}; - -const _$AudioSourceEnumMap = { - AudioSource.youtube: 'youtube', - AudioSource.piped: 'piped', - AudioSource.jiosaavn: 'jiosaavn', -}; - -const _$SourceCodecsEnumMap = { - SourceCodecs.m4a: 'm4a', - SourceCodecs.weba: 'weba', -}; diff --git a/lib/services/sourced_track/models/video_info.dart b/lib/services/sourced_track/models/video_info.dart index 031a8943..58dd0280 100644 --- a/lib/services/sourced_track/models/video_info.dart +++ b/lib/services/sourced_track/models/video_info.dart @@ -1,5 +1,6 @@ import 'package:piped_client/piped_client.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; +import 'package:spotube/models/database/database.dart'; + import 'package:youtube_explode_dart/youtube_explode_dart.dart'; class YoutubeVideoInfo { diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart index 7eedfad8..977b980b 100644 --- a/lib/services/sourced_track/sourced_track.dart +++ b/lib/services/sourced_track/sourced_track.dart @@ -5,8 +5,9 @@ import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index 8444db53..b6689f6a 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -2,9 +2,10 @@ import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; + import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e22c5732..b8e26367 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -41,6 +42,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) screen_retriever_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); g_autoptr(FlPluginRegistrar) system_theme_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin"); system_theme_plugin_register_with_registrar(system_theme_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 9ddc2b98..20d4a4dd 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST local_notifier media_kit_libs_linux screen_retriever + sqlite3_flutter_libs system_theme system_tray tray_manager diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 047e7f3d..54546705 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -20,6 +20,7 @@ import path_provider_foundation import screen_retriever import shared_preferences_foundation import sqflite +import sqlite3_flutter_libs import system_theme import system_tray import tray_manager @@ -42,6 +43,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index fcba2934..58d09cd9 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -39,6 +39,21 @@ PODS: - sqflite (0.0.3): - Flutter - FlutterMacOS + - sqlite3 (3.46.0): + - sqlite3/common (= 3.46.0) + - sqlite3/common (3.46.0) + - sqlite3/fts5 (3.46.0): + - sqlite3/common + - sqlite3/perf-threadsafe (3.46.0): + - sqlite3/common + - sqlite3/rtree (3.46.0): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - FlutterMacOS + - sqlite3 (~> 3.46.0) + - sqlite3/fts5 + - sqlite3/perf-threadsafe + - sqlite3/rtree - system_theme (0.0.1): - FlutterMacOS - system_tray (0.0.1): @@ -69,6 +84,7 @@ DEPENDENCIES: - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) + - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos`) - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) - system_tray (from `Flutter/ephemeral/.symlinks/plugins/system_tray/macos`) - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) @@ -78,6 +94,7 @@ DEPENDENCIES: SPEC REPOS: trunk: - OrderedSet + - sqlite3 EXTERNAL SOURCES: app_links: @@ -116,6 +133,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite: :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin + sqlite3_flutter_libs: + :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos system_theme: :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos system_tray: @@ -147,6 +166,8 @@ SPEC CHECKSUMS: screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + sqlite3: 154b084339ede06960a5b3c8160066adc9176b7d + sqlite3_flutter_libs: 1be4459672f8168ded2d8667599b8e3ca5e72b83 system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc system_tray: e53c972838c69589ff2e77d6d3abfd71332f9e5d tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 diff --git a/pubspec.lock b/pubspec.lock index c1866e7d..c5871a2a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -289,6 +289,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" checked_yaml: dependency: transitive description: @@ -507,6 +515,22 @@ packages: url: "https://github.com/thielepaul/flutter-draggable-scrollbar.git" source: git version: "0.1.0" + drift: + dependency: "direct main" + description: + name: drift + sha256: "6acedc562ffeed308049f78fb1906abad3d65714580b6745441ee6d50ec564cd" + url: "https://pub.dev" + source: hosted + version: "2.18.0" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: d9b020736ea85fff1568699ce18b89fabb3f0f042e8a7a05e84a3ec20d39acde + url: "https://pub.dev" + source: hosted + version: "2.18.0" duration: dependency: "direct main" description: @@ -1997,6 +2021,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.4" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: b384f598b813b347c5a7e5ffad82cbaff1bec3d1561af267041e66f6f0899295 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: "9f89a7e7dc36eac2035808427eba1c3fbd79e59c3a22093d8dace6d36b1fe89e" + url: "https://pub.dev" + source: hosted + version: "0.5.23" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: ade9a67fd70d0369329ed3373208de7ebd8662470e8c396fc8d0d60f9acdfc9f + url: "https://pub.dev" + source: hosted + version: "0.36.0" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bd1717c8..ddace46e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -121,6 +121,9 @@ dependencies: tray_manager: ^0.2.2 http: ^1.2.1 riverpod: ^2.5.1 + drift: ^2.18.0 + sqlite3_flutter_libs: ^0.5.23 + sqlite3: ^2.4.3 dev_dependencies: build_runner: ^2.4.11 @@ -143,6 +146,7 @@ dev_dependencies: pub_api_client: ^2.4.0 xml: ^6.5.0 io: ^1.0.4 + drift_dev: ^2.18.0 dependency_overrides: uuid: ^4.4.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 559db310..b978edb9 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); ScreenRetrieverPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); + Sqlite3FlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); SystemThemePluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SystemThemePlugin")); SystemTrayPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d1464df5..4fcc467a 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST media_kit_libs_windows_audio permission_handler_windows screen_retriever + sqlite3_flutter_libs system_theme system_tray tray_manager From 52d4f60ccc59dcbe7b1229dd4a7c8a2226c3b6b0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 14 Jun 2024 21:24:42 +0600 Subject: [PATCH 139/261] refactor: use drift for skip segments and source matches --- lib/main.dart | 19 - lib/models/database/database.dart | 5 +- lib/models/database/database.g.dart | 878 +++++++++++++++++- lib/models/database/tables/skip_segment.dart | 9 + lib/models/database/tables/source_match.dart | 25 + lib/models/skip_segment.dart | 25 - lib/models/skip_segment.g.dart | 44 - lib/models/source_match.dart | 54 -- lib/models/source_match.g.dart | 119 --- .../proxy_playlist/skip_segments.dart | 50 +- .../sourced_track/sources/jiosaavn.dart | 42 +- lib/services/sourced_track/sources/piped.dart | 47 +- .../sourced_track/sources/youtube.dart | 42 +- lib/utils/service_utils.dart | 11 +- 14 files changed, 1019 insertions(+), 351 deletions(-) create mode 100644 lib/models/database/tables/skip_segment.dart create mode 100644 lib/models/database/tables/source_match.dart delete mode 100644 lib/models/skip_segment.dart delete mode 100644 lib/models/skip_segment.g.dart delete mode 100644 lib/models/source_match.dart delete mode 100644 lib/models/source_match.g.dart diff --git a/lib/main.dart b/lib/main.dart index 1f5e5909..bdccadd4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,8 +22,6 @@ import 'package:spotube/provider/server/bonsoir.dart'; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/provider/tray_manager/tray_manager.dart'; import 'package:spotube/l10n/l10n.dart'; -import 'package:spotube/models/skip_segment.dart'; -import 'package:spotube/models/source_match.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -84,23 +82,6 @@ Future main(List rawArgs) async { Hive.init(hiveCacheDir); - Hive.registerAdapter(SkipSegmentAdapter()); - - Hive.registerAdapter(SourceMatchAdapter()); - Hive.registerAdapter(SourceTypeAdapter()); - - // Cache versioning entities with Adapter - SourceMatch.version = 'v1'; - SkipSegment.version = 'v1'; - - await Hive.openLazyBox( - SourceMatch.boxName, - path: hiveCacheDir, - ); - await Hive.openLazyBox( - SkipSegment.boxName, - path: hiveCacheDir, - ); await PersistedStateNotifier.initializeBoxes( path: hiveCacheDir, ); diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 7d8fe088..e7ac2558 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -17,11 +17,14 @@ import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; part 'database.g.dart'; part 'tables/preferences.dart'; +part 'tables/source_match.dart'; +part 'tables/skip_segment.dart'; + part 'typeconverters/color.dart'; part 'typeconverters/locale.dart'; part 'typeconverters/string_list.dart'; -@DriftDatabase(tables: [PreferencesTable]) +@DriftDatabase(tables: [PreferencesTable, SourceMatchTable, SkipSegmentTable]) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index 1516b266..9cc8a1c1 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -1204,16 +1204,608 @@ class PreferencesTableCompanion extends UpdateCompanion { } } +class $SourceMatchTableTable extends SourceMatchTable + with TableInfo<$SourceMatchTableTable, SourceMatchTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SourceMatchTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _trackIdMeta = + const VerificationMeta('trackId'); + @override + late final GeneratedColumn trackId = GeneratedColumn( + 'track_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _sourceIdMeta = + const VerificationMeta('sourceId'); + @override + late final GeneratedColumn sourceId = GeneratedColumn( + 'source_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _sourceTypeMeta = + const VerificationMeta('sourceType'); + @override + late final GeneratedColumnWithTypeConverter sourceType = + GeneratedColumn('source_type', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: Constant(SourceType.youtube.name)) + .withConverter( + $SourceMatchTableTable.$convertersourceType); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime); + @override + List get $columns => + [id, trackId, sourceId, sourceType, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'source_match_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('track_id')) { + context.handle(_trackIdMeta, + trackId.isAcceptableOrUnknown(data['track_id']!, _trackIdMeta)); + } else if (isInserting) { + context.missing(_trackIdMeta); + } + if (data.containsKey('source_id')) { + context.handle(_sourceIdMeta, + sourceId.isAcceptableOrUnknown(data['source_id']!, _sourceIdMeta)); + } else if (isInserting) { + context.missing(_sourceIdMeta); + } + context.handle(_sourceTypeMeta, const VerificationResult.success()); + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + SourceMatchTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SourceMatchTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + trackId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}track_id'])!, + sourceId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}source_id'])!, + sourceType: $SourceMatchTableTable.$convertersourceType.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}source_type'])!), + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + ); + } + + @override + $SourceMatchTableTable createAlias(String alias) { + return $SourceMatchTableTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 $convertersourceType = + const EnumNameConverter(SourceType.values); +} + +class SourceMatchTableData extends DataClass + implements Insertable { + final int id; + final String trackId; + final String sourceId; + final SourceType sourceType; + final DateTime createdAt; + const SourceMatchTableData( + {required this.id, + required this.trackId, + required this.sourceId, + required this.sourceType, + required this.createdAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['track_id'] = Variable(trackId); + map['source_id'] = Variable(sourceId); + { + map['source_type'] = Variable( + $SourceMatchTableTable.$convertersourceType.toSql(sourceType)); + } + map['created_at'] = Variable(createdAt); + return map; + } + + SourceMatchTableCompanion toCompanion(bool nullToAbsent) { + return SourceMatchTableCompanion( + id: Value(id), + trackId: Value(trackId), + sourceId: Value(sourceId), + sourceType: Value(sourceType), + createdAt: Value(createdAt), + ); + } + + factory SourceMatchTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SourceMatchTableData( + id: serializer.fromJson(json['id']), + trackId: serializer.fromJson(json['trackId']), + sourceId: serializer.fromJson(json['sourceId']), + sourceType: $SourceMatchTableTable.$convertersourceType + .fromJson(serializer.fromJson(json['sourceType'])), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'trackId': serializer.toJson(trackId), + 'sourceId': serializer.toJson(sourceId), + 'sourceType': serializer.toJson( + $SourceMatchTableTable.$convertersourceType.toJson(sourceType)), + 'createdAt': serializer.toJson(createdAt), + }; + } + + SourceMatchTableData copyWith( + {int? id, + String? trackId, + String? sourceId, + SourceType? sourceType, + DateTime? createdAt}) => + SourceMatchTableData( + id: id ?? this.id, + trackId: trackId ?? this.trackId, + sourceId: sourceId ?? this.sourceId, + sourceType: sourceType ?? this.sourceType, + createdAt: createdAt ?? this.createdAt, + ); + @override + String toString() { + return (StringBuffer('SourceMatchTableData(') + ..write('id: $id, ') + ..write('trackId: $trackId, ') + ..write('sourceId: $sourceId, ') + ..write('sourceType: $sourceType, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, trackId, sourceId, sourceType, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SourceMatchTableData && + other.id == this.id && + other.trackId == this.trackId && + other.sourceId == this.sourceId && + other.sourceType == this.sourceType && + other.createdAt == this.createdAt); +} + +class SourceMatchTableCompanion extends UpdateCompanion { + final Value id; + final Value trackId; + final Value sourceId; + final Value sourceType; + final Value createdAt; + const SourceMatchTableCompanion({ + this.id = const Value.absent(), + this.trackId = const Value.absent(), + this.sourceId = const Value.absent(), + this.sourceType = const Value.absent(), + this.createdAt = const Value.absent(), + }); + SourceMatchTableCompanion.insert({ + this.id = const Value.absent(), + required String trackId, + required String sourceId, + this.sourceType = const Value.absent(), + this.createdAt = const Value.absent(), + }) : trackId = Value(trackId), + sourceId = Value(sourceId); + static Insertable custom({ + Expression? id, + Expression? trackId, + Expression? sourceId, + Expression? sourceType, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (trackId != null) 'track_id': trackId, + if (sourceId != null) 'source_id': sourceId, + if (sourceType != null) 'source_type': sourceType, + if (createdAt != null) 'created_at': createdAt, + }); + } + + SourceMatchTableCompanion copyWith( + {Value? id, + Value? trackId, + Value? sourceId, + Value? sourceType, + Value? createdAt}) { + return SourceMatchTableCompanion( + id: id ?? this.id, + trackId: trackId ?? this.trackId, + sourceId: sourceId ?? this.sourceId, + sourceType: sourceType ?? this.sourceType, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (trackId.present) { + map['track_id'] = Variable(trackId.value); + } + if (sourceId.present) { + map['source_id'] = Variable(sourceId.value); + } + if (sourceType.present) { + map['source_type'] = Variable( + $SourceMatchTableTable.$convertersourceType.toSql(sourceType.value)); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SourceMatchTableCompanion(') + ..write('id: $id, ') + ..write('trackId: $trackId, ') + ..write('sourceId: $sourceId, ') + ..write('sourceType: $sourceType, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + +class $SkipSegmentTableTable extends SkipSegmentTable + with TableInfo<$SkipSegmentTableTable, SkipSegmentTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SkipSegmentTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _startMeta = const VerificationMeta('start'); + @override + late final GeneratedColumn start = GeneratedColumn( + 'start', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _endMeta = const VerificationMeta('end'); + @override + late final GeneratedColumn end = GeneratedColumn( + 'end', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _trackIdMeta = + const VerificationMeta('trackId'); + @override + late final GeneratedColumn trackId = GeneratedColumn( + 'track_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime); + @override + List get $columns => [id, start, end, trackId, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'skip_segment_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('start')) { + context.handle( + _startMeta, start.isAcceptableOrUnknown(data['start']!, _startMeta)); + } else if (isInserting) { + context.missing(_startMeta); + } + if (data.containsKey('end')) { + context.handle( + _endMeta, end.isAcceptableOrUnknown(data['end']!, _endMeta)); + } else if (isInserting) { + context.missing(_endMeta); + } + if (data.containsKey('track_id')) { + context.handle(_trackIdMeta, + trackId.isAcceptableOrUnknown(data['track_id']!, _trackIdMeta)); + } else if (isInserting) { + context.missing(_trackIdMeta); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + SkipSegmentTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SkipSegmentTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + start: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}start'])!, + end: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}end'])!, + trackId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}track_id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + ); + } + + @override + $SkipSegmentTableTable createAlias(String alias) { + return $SkipSegmentTableTable(attachedDatabase, alias); + } +} + +class SkipSegmentTableData extends DataClass + implements Insertable { + final int id; + final int start; + final int end; + final String trackId; + final DateTime createdAt; + const SkipSegmentTableData( + {required this.id, + required this.start, + required this.end, + required this.trackId, + required this.createdAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['start'] = Variable(start); + map['end'] = Variable(end); + map['track_id'] = Variable(trackId); + map['created_at'] = Variable(createdAt); + return map; + } + + SkipSegmentTableCompanion toCompanion(bool nullToAbsent) { + return SkipSegmentTableCompanion( + id: Value(id), + start: Value(start), + end: Value(end), + trackId: Value(trackId), + createdAt: Value(createdAt), + ); + } + + factory SkipSegmentTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SkipSegmentTableData( + id: serializer.fromJson(json['id']), + start: serializer.fromJson(json['start']), + end: serializer.fromJson(json['end']), + trackId: serializer.fromJson(json['trackId']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'start': serializer.toJson(start), + 'end': serializer.toJson(end), + 'trackId': serializer.toJson(trackId), + 'createdAt': serializer.toJson(createdAt), + }; + } + + SkipSegmentTableData copyWith( + {int? id, + int? start, + int? end, + String? trackId, + DateTime? createdAt}) => + SkipSegmentTableData( + id: id ?? this.id, + start: start ?? this.start, + end: end ?? this.end, + trackId: trackId ?? this.trackId, + createdAt: createdAt ?? this.createdAt, + ); + @override + String toString() { + return (StringBuffer('SkipSegmentTableData(') + ..write('id: $id, ') + ..write('start: $start, ') + ..write('end: $end, ') + ..write('trackId: $trackId, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, start, end, trackId, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SkipSegmentTableData && + other.id == this.id && + other.start == this.start && + other.end == this.end && + other.trackId == this.trackId && + other.createdAt == this.createdAt); +} + +class SkipSegmentTableCompanion extends UpdateCompanion { + final Value id; + final Value start; + final Value end; + final Value trackId; + final Value createdAt; + const SkipSegmentTableCompanion({ + this.id = const Value.absent(), + this.start = const Value.absent(), + this.end = const Value.absent(), + this.trackId = const Value.absent(), + this.createdAt = const Value.absent(), + }); + SkipSegmentTableCompanion.insert({ + this.id = const Value.absent(), + required int start, + required int end, + required String trackId, + this.createdAt = const Value.absent(), + }) : start = Value(start), + end = Value(end), + trackId = Value(trackId); + static Insertable custom({ + Expression? id, + Expression? start, + Expression? end, + Expression? trackId, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (start != null) 'start': start, + if (end != null) 'end': end, + if (trackId != null) 'track_id': trackId, + if (createdAt != null) 'created_at': createdAt, + }); + } + + SkipSegmentTableCompanion copyWith( + {Value? id, + Value? start, + Value? end, + Value? trackId, + Value? createdAt}) { + return SkipSegmentTableCompanion( + id: id ?? this.id, + start: start ?? this.start, + end: end ?? this.end, + trackId: trackId ?? this.trackId, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (start.present) { + map['start'] = Variable(start.value); + } + if (end.present) { + map['end'] = Variable(end.value); + } + if (trackId.present) { + map['track_id'] = Variable(trackId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SkipSegmentTableCompanion(') + ..write('id: $id, ') + ..write('start: $start, ') + ..write('end: $end, ') + ..write('trackId: $trackId, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); _$AppDatabaseManager get managers => _$AppDatabaseManager(this); late final $PreferencesTableTable preferencesTable = $PreferencesTableTable(this); + late final $SourceMatchTableTable sourceMatchTable = + $SourceMatchTableTable(this); + late final $SkipSegmentTableTable skipSegmentTable = + $SkipSegmentTableTable(this); + late final Index uniqTrackMatch = Index('uniq_track_match', + 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [preferencesTable]; + List get allSchemaEntities => + [preferencesTable, sourceMatchTable, skipSegmentTable, uniqTrackMatch]; } typedef $$PreferencesTableTableInsertCompanionBuilder @@ -1699,9 +2291,293 @@ class $$PreferencesTableTableOrderingComposer ColumnOrderings(column, joinBuilders: joinBuilders)); } +typedef $$SourceMatchTableTableInsertCompanionBuilder + = SourceMatchTableCompanion Function({ + Value id, + required String trackId, + required String sourceId, + Value sourceType, + Value createdAt, +}); +typedef $$SourceMatchTableTableUpdateCompanionBuilder + = SourceMatchTableCompanion Function({ + Value id, + Value trackId, + Value sourceId, + Value sourceType, + Value createdAt, +}); + +class $$SourceMatchTableTableTableManager extends RootTableManager< + _$AppDatabase, + $SourceMatchTableTable, + SourceMatchTableData, + $$SourceMatchTableTableFilterComposer, + $$SourceMatchTableTableOrderingComposer, + $$SourceMatchTableTableProcessedTableManager, + $$SourceMatchTableTableInsertCompanionBuilder, + $$SourceMatchTableTableUpdateCompanionBuilder> { + $$SourceMatchTableTableTableManager( + _$AppDatabase db, $SourceMatchTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$SourceMatchTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$SourceMatchTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$SourceMatchTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value trackId = const Value.absent(), + Value sourceId = const Value.absent(), + Value sourceType = const Value.absent(), + Value createdAt = const Value.absent(), + }) => + SourceMatchTableCompanion( + id: id, + trackId: trackId, + sourceId: sourceId, + sourceType: sourceType, + createdAt: createdAt, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required String trackId, + required String sourceId, + Value sourceType = const Value.absent(), + Value createdAt = const Value.absent(), + }) => + SourceMatchTableCompanion.insert( + id: id, + trackId: trackId, + sourceId: sourceId, + sourceType: sourceType, + createdAt: createdAt, + ), + )); +} + +class $$SourceMatchTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $SourceMatchTableTable, + SourceMatchTableData, + $$SourceMatchTableTableFilterComposer, + $$SourceMatchTableTableOrderingComposer, + $$SourceMatchTableTableProcessedTableManager, + $$SourceMatchTableTableInsertCompanionBuilder, + $$SourceMatchTableTableUpdateCompanionBuilder> { + $$SourceMatchTableTableProcessedTableManager(super.$state); +} + +class $$SourceMatchTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $SourceMatchTableTable> { + $$SourceMatchTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get sourceId => $state.composableBuilder( + column: $state.table.sourceId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get sourceType => $state.composableBuilder( + column: $state.table.sourceType, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$SourceMatchTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $SourceMatchTableTable> { + $$SourceMatchTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get sourceId => $state.composableBuilder( + column: $state.table.sourceId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get sourceType => $state.composableBuilder( + column: $state.table.sourceType, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$SkipSegmentTableTableInsertCompanionBuilder + = SkipSegmentTableCompanion Function({ + Value id, + required int start, + required int end, + required String trackId, + Value createdAt, +}); +typedef $$SkipSegmentTableTableUpdateCompanionBuilder + = SkipSegmentTableCompanion Function({ + Value id, + Value start, + Value end, + Value trackId, + Value createdAt, +}); + +class $$SkipSegmentTableTableTableManager extends RootTableManager< + _$AppDatabase, + $SkipSegmentTableTable, + SkipSegmentTableData, + $$SkipSegmentTableTableFilterComposer, + $$SkipSegmentTableTableOrderingComposer, + $$SkipSegmentTableTableProcessedTableManager, + $$SkipSegmentTableTableInsertCompanionBuilder, + $$SkipSegmentTableTableUpdateCompanionBuilder> { + $$SkipSegmentTableTableTableManager( + _$AppDatabase db, $SkipSegmentTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$SkipSegmentTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$SkipSegmentTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$SkipSegmentTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value start = const Value.absent(), + Value end = const Value.absent(), + Value trackId = const Value.absent(), + Value createdAt = const Value.absent(), + }) => + SkipSegmentTableCompanion( + id: id, + start: start, + end: end, + trackId: trackId, + createdAt: createdAt, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required int start, + required int end, + required String trackId, + Value createdAt = const Value.absent(), + }) => + SkipSegmentTableCompanion.insert( + id: id, + start: start, + end: end, + trackId: trackId, + createdAt: createdAt, + ), + )); +} + +class $$SkipSegmentTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $SkipSegmentTableTable, + SkipSegmentTableData, + $$SkipSegmentTableTableFilterComposer, + $$SkipSegmentTableTableOrderingComposer, + $$SkipSegmentTableTableProcessedTableManager, + $$SkipSegmentTableTableInsertCompanionBuilder, + $$SkipSegmentTableTableUpdateCompanionBuilder> { + $$SkipSegmentTableTableProcessedTableManager(super.$state); +} + +class $$SkipSegmentTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $SkipSegmentTableTable> { + $$SkipSegmentTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get start => $state.composableBuilder( + column: $state.table.start, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get end => $state.composableBuilder( + column: $state.table.end, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$SkipSegmentTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $SkipSegmentTableTable> { + $$SkipSegmentTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get start => $state.composableBuilder( + column: $state.table.start, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get end => $state.composableBuilder( + column: $state.table.end, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + class _$AppDatabaseManager { final _$AppDatabase _db; _$AppDatabaseManager(this._db); $$PreferencesTableTableTableManager get preferencesTable => $$PreferencesTableTableTableManager(_db, _db.preferencesTable); + $$SourceMatchTableTableTableManager get sourceMatchTable => + $$SourceMatchTableTableTableManager(_db, _db.sourceMatchTable); + $$SkipSegmentTableTableTableManager get skipSegmentTable => + $$SkipSegmentTableTableTableManager(_db, _db.skipSegmentTable); } diff --git a/lib/models/database/tables/skip_segment.dart b/lib/models/database/tables/skip_segment.dart new file mode 100644 index 00000000..719f2617 --- /dev/null +++ b/lib/models/database/tables/skip_segment.dart @@ -0,0 +1,9 @@ +part of '../database.dart'; + +class SkipSegmentTable extends Table { + IntColumn get id => integer().autoIncrement()(); + IntColumn get start => integer()(); + IntColumn get end => integer()(); + TextColumn get trackId => text()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} diff --git a/lib/models/database/tables/source_match.dart b/lib/models/database/tables/source_match.dart new file mode 100644 index 00000000..78d0eb05 --- /dev/null +++ b/lib/models/database/tables/source_match.dart @@ -0,0 +1,25 @@ +part of '../database.dart'; + +enum SourceType { + youtube._("YouTube"), + youtubeMusic._("YouTube Music"), + jiosaavn._("JioSaavn"); + + final String label; + + const SourceType._(this.label); +} + +@TableIndex( + name: "uniq_track_match", + columns: {#trackId, #sourceId, #sourceType}, + unique: true, +) +class SourceMatchTable extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get trackId => text()(); + TextColumn get sourceId => text()(); + TextColumn get sourceType => + textEnum().withDefault(Constant(SourceType.youtube.name))(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); +} diff --git a/lib/models/skip_segment.dart b/lib/models/skip_segment.dart deleted file mode 100644 index 90f20f5a..00000000 --- a/lib/models/skip_segment.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:hive/hive.dart'; - -part 'skip_segment.g.dart'; - -@HiveType(typeId: 2) -class SkipSegment { - @HiveField(0) - final int start; - @HiveField(1) - final int end; - SkipSegment(this.start, this.end); - - static String version = 'v1'; - static final boxName = "oss.krtirtho.spotube.skip_segments.$version"; - static LazyBox get box => Hive.lazyBox(boxName); - - SkipSegment.fromJson(Map json) - : start = json['start'], - end = json['end']; - - Map toJson() => { - 'start': start, - 'end': end, - }; -} diff --git a/lib/models/skip_segment.g.dart b/lib/models/skip_segment.g.dart deleted file mode 100644 index f2ad4459..00000000 --- a/lib/models/skip_segment.g.dart +++ /dev/null @@ -1,44 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'skip_segment.dart'; - -// ************************************************************************** -// TypeAdapterGenerator -// ************************************************************************** - -class SkipSegmentAdapter extends TypeAdapter { - @override - final int typeId = 2; - - @override - SkipSegment read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return SkipSegment( - fields[0] as int, - fields[1] as int, - ); - } - - @override - void write(BinaryWriter writer, SkipSegment obj) { - writer - ..writeByte(2) - ..writeByte(0) - ..write(obj.start) - ..writeByte(1) - ..write(obj.end); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SkipSegmentAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} diff --git a/lib/models/source_match.dart b/lib/models/source_match.dart deleted file mode 100644 index 57a9f963..00000000 --- a/lib/models/source_match.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:hive/hive.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'source_match.g.dart'; - -@JsonEnum() -@HiveType(typeId: 5) -enum SourceType { - @HiveField(0) - youtube._("YouTube"), - - @HiveField(1) - youtubeMusic._("YouTube Music"), - - @HiveField(2) - jiosaavn._("JioSaavn"); - - final String label; - - const SourceType._(this.label); -} - -@JsonSerializable() -@HiveType(typeId: 6) -class SourceMatch { - @HiveField(0) - String id; - - @HiveField(1) - String sourceId; - - @HiveField(2) - SourceType sourceType; - - @HiveField(3) - DateTime createdAt; - - SourceMatch({ - required this.id, - required this.sourceId, - required this.sourceType, - required this.createdAt, - }); - - factory SourceMatch.fromJson(Map json) => - _$SourceMatchFromJson(json); - - Map toJson() => _$SourceMatchToJson(this); - - static String version = 'v1'; - static final boxName = "oss.krtirtho.spotube.source_matches.$version"; - - static LazyBox get box => Hive.lazyBox(boxName); -} diff --git a/lib/models/source_match.g.dart b/lib/models/source_match.g.dart deleted file mode 100644 index 3b469694..00000000 --- a/lib/models/source_match.g.dart +++ /dev/null @@ -1,119 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'source_match.dart'; - -// ************************************************************************** -// TypeAdapterGenerator -// ************************************************************************** - -class SourceMatchAdapter extends TypeAdapter { - @override - final int typeId = 6; - - @override - SourceMatch read(BinaryReader reader) { - final numOfFields = reader.readByte(); - final fields = { - for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), - }; - return SourceMatch( - id: fields[0] as String, - sourceId: fields[1] as String, - sourceType: fields[2] as SourceType, - createdAt: fields[3] as DateTime, - ); - } - - @override - void write(BinaryWriter writer, SourceMatch obj) { - writer - ..writeByte(4) - ..writeByte(0) - ..write(obj.id) - ..writeByte(1) - ..write(obj.sourceId) - ..writeByte(2) - ..write(obj.sourceType) - ..writeByte(3) - ..write(obj.createdAt); - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SourceMatchAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -class SourceTypeAdapter extends TypeAdapter { - @override - final int typeId = 5; - - @override - SourceType read(BinaryReader reader) { - switch (reader.readByte()) { - case 0: - return SourceType.youtube; - case 1: - return SourceType.youtubeMusic; - case 2: - return SourceType.jiosaavn; - default: - return SourceType.youtube; - } - } - - @override - void write(BinaryWriter writer, SourceType obj) { - switch (obj) { - case SourceType.youtube: - writer.writeByte(0); - break; - case SourceType.youtubeMusic: - writer.writeByte(1); - break; - case SourceType.jiosaavn: - writer.writeByte(2); - break; - } - } - - @override - int get hashCode => typeId.hashCode; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SourceTypeAdapter && - runtimeType == other.runtimeType && - typeId == other.typeId; -} - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( - id: json['id'] as String, - sourceId: json['sourceId'] as String, - sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']), - createdAt: DateTime.parse(json['createdAt'] as String), - ); - -Map _$SourceMatchToJson(SourceMatch instance) => - { - 'id': instance.id, - 'sourceId': instance.sourceId, - 'sourceType': _$SourceTypeEnumMap[instance.sourceType]!, - 'createdAt': instance.createdAt.toIso8601String(), - }; - -const _$SourceTypeEnumMap = { - SourceType.youtube: 'youtube', - SourceType.youtubeMusic: 'youtubeMusic', - SourceType.jiosaavn: 'jiosaavn', -}; diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/proxy_playlist/skip_segments.dart index 461ac24e..005797f4 100644 --- a/lib/provider/proxy_playlist/skip_segments.dart +++ b/lib/provider/proxy_playlist/skip_segments.dart @@ -1,8 +1,8 @@ import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:dio/dio.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -10,24 +10,21 @@ import 'package:spotube/services/dio/dio.dart'; class SourcedSegments { final String source; - final List segments; + final List segments; SourcedSegments({required this.source, required this.segments}); } -Future> getAndCacheSkipSegments(String id) async { +Future> getAndCacheSkipSegments( + String id, Ref ref) async { + final database = ref.read(databaseProvider); try { - final cached = await SkipSegment.box.get(id) as List?; - if (cached != null && cached.isNotEmpty) { - return List.castFrom( - cached - .map( - (json) => SkipSegment.fromJson( - Map.castFrom(json), - ), - ) - .toList(), - ); + final cached = await (database.select(database.skipSegmentTable) + ..where((s) => s.trackId.equals(id))) + .get(); + + if (cached.isNotEmpty) { + return cached; } final res = await globalDio.getUri( @@ -55,25 +52,30 @@ Future> getAndCacheSkipSegments(String id) async { ); if (res.data == "Not Found") { - return List.castFrom([]); + return List.castFrom([]); } final data = res.data as List; final segments = data.map((obj) { final start = obj["segment"].first.toInt(); final end = obj["segment"].last.toInt(); - return SkipSegment(start, end); + return SkipSegmentTableCompanion.insert( + trackId: id, + start: start, + end: end, + ); }).toList(); - await SkipSegment.box.put( - id, - segments.map((e) => e.toJson()).toList(), - ); - return List.castFrom(segments); + await database.batch((b) { + b.insertAll(database.skipSegmentTable, segments); + }); + + return await (database.select(database.skipSegmentTable) + ..where((s) => s.trackId.equals(id))) + .get(); } catch (e, stack) { - await SkipSegment.box.put(id, []); AppLogger.reportError(e, stack); - return List.castFrom([]); + return List.castFrom([]); } } @@ -100,7 +102,7 @@ final segmentProvider = FutureProvider( ); } - final segments = await getAndCacheSkipSegments(track.sourceInfo.id); + final segments = await getAndCacheSkipSegments(track.sourceInfo.id, ref); return SourcedSegments( source: track.sourceInfo.id, diff --git a/lib/services/sourced_track/sources/jiosaavn.dart b/lib/services/sourced_track/sources/jiosaavn.dart index f731de6c..865e3d63 100644 --- a/lib/services/sourced_track/sources/jiosaavn.dart +++ b/lib/services/sourced_track/sources/jiosaavn.dart @@ -1,7 +1,9 @@ import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/source_match.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/services/sourced_track/exceptions.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; @@ -39,7 +41,10 @@ class JioSaavnSourcedTrack extends SourcedTrack { required Ref ref, bool weakMatch = false, }) async { - final cachedSource = await SourceMatch.box.get(track.id); + final database = ref.read(databaseProvider); + final cachedSource = await (database.select(database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!))) + .getSingleOrNull(); if (cachedSource == null || cachedSource.sourceType != SourceType.jiosaavn) { @@ -50,15 +55,13 @@ class JioSaavnSourcedTrack extends SourcedTrack { throw TrackNotFoundError(track); } - await SourceMatch.box.put( - track.id!, - SourceMatch( - id: track.id!, - sourceType: SourceType.jiosaavn, - createdAt: DateTime.now(), - sourceId: siblings.first.info.id, - ), - ); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: const Value(SourceType.jiosaavn), + ), + ); return JioSaavnSourcedTrack( ref: ref, @@ -206,15 +209,14 @@ class JioSaavnSourcedTrack extends SourcedTrack { final (:info, :source) = toSiblingType(item); - await SourceMatch.box.put( - id!, - SourceMatch( - id: id!, - sourceType: SourceType.jiosaavn, - createdAt: DateTime.now(), - sourceId: info.id, - ), - ); + final database = ref.read(databaseProvider); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: info.id, + sourceType: const Value(SourceType.jiosaavn), + ), + ); return JioSaavnSourcedTrack( ref: ref, diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index b6689f6a..d156b26e 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -1,9 +1,10 @@ import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:piped_client/piped_client.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; -import 'package:spotube/models/source_match.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -49,7 +50,10 @@ class PipedSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) async { - final cachedSource = await SourceMatch.box.get(track.id); + final database = ref.read(databaseProvider); + final cachedSource = await (database.select(database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!))) + .getSingleOrNull(); final preferences = ref.read(userPreferencesProvider); final pipedClient = ref.read(pipedProvider); @@ -59,17 +63,17 @@ class PipedSourcedTrack extends SourcedTrack { throw TrackNotFoundError(track); } - await SourceMatch.box.put( - track.id!, - SourceMatch( - id: track.id!, - sourceType: preferences.searchMode == SearchMode.youtube - ? SourceType.youtube - : SourceType.youtubeMusic, - createdAt: DateTime.now(), - sourceId: siblings.first.info.id, - ), - ); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: Value( + preferences.searchMode == SearchMode.youtube + ? SourceType.youtube + : SourceType.youtubeMusic, + ), + ), + ); return PipedSourcedTrack( ref: ref, @@ -268,15 +272,14 @@ class PipedSourcedTrack extends SourcedTrack { final manifest = await pipedClient.streams(newSourceInfo.id); - await SourceMatch.box.put( - id!, - SourceMatch( - id: id!, - sourceType: SourceType.jiosaavn, - createdAt: DateTime.now(), - sourceId: newSourceInfo.id, - ), - ); + final database = ref.read(databaseProvider); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: newSourceInfo.id, + sourceType: const Value(SourceType.youtube), + ), + ); return PipedSourcedTrack( ref: ref, diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index b144d701..0501a499 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -1,8 +1,10 @@ import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/models/source_match.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/song_link/song_link.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -46,7 +48,10 @@ class YoutubeSourcedTrack extends SourcedTrack { required Track track, required Ref ref, }) async { - final cachedSource = await SourceMatch.box.get(track.id); + final database = ref.read(databaseProvider); + final cachedSource = await (database.select(database.sourceMatchTable) + ..where((s) => s.trackId.equals(track.id!))) + .getSingleOrNull(); if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) { final siblings = await fetchSiblings(ref: ref, track: track); @@ -54,15 +59,13 @@ class YoutubeSourcedTrack extends SourcedTrack { throw TrackNotFoundError(track); } - await SourceMatch.box.put( - track.id!, - SourceMatch( - id: track.id!, - sourceType: SourceType.youtube, - createdAt: DateTime.now(), - sourceId: siblings.first.info.id, - ), - ); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: track.id!, + sourceId: siblings.first.info.id, + sourceType: const Value(SourceType.youtube), + ), + ); return YoutubeSourcedTrack( ref: ref, @@ -283,15 +286,14 @@ class YoutubeSourcedTrack extends SourcedTrack { onTimeout: () => throw ClientException("Timeout"), ); - await SourceMatch.box.put( - id!, - SourceMatch( - id: id!, - sourceType: SourceType.jiosaavn, - createdAt: DateTime.now(), - sourceId: newSourceInfo.id, - ), - ); + final database = ref.read(databaseProvider); + await database.into(database.sourceMatchTable).insert( + SourceMatchTableCompanion.insert( + trackId: id!, + sourceId: newSourceInfo.id, + sourceType: const Value(SourceType.youtube), + ), + ); return YoutubeSourcedTrack( ref: ref, diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 885f9a2c..5950bc8c 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -6,6 +6,7 @@ import 'package:spotube/modules/library/user_local_tracks.dart'; import 'package:spotube/modules/root/update_dialog.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/lyrics.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -20,7 +21,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:spotube/collections/env.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:version/version.dart'; abstract class ServiceUtils { @@ -392,7 +392,14 @@ abstract class ServiceUtils { WidgetRef ref, ) async { if (!Env.enableUpdateChecker) return; - if (!ref.read(userPreferencesProvider.select((s) => s.checkUpdate))) return; + final database = ref.read(databaseProvider); + final checkUpdate = await (database.selectOnly(database.preferencesTable) + ..addColumns([database.preferencesTable.checkUpdate]) + ..where(database.preferencesTable.id.equals(0))) + .map((row) => row.read(database.preferencesTable.checkUpdate)) + .getSingleOrNull(); + + if (checkUpdate == false) return; final packageInfo = await PackageInfo.fromPlatform(); if (Env.releaseChannel == ReleaseChannel.nightly) { From bf6cec8d6939bd373f6015af08d8ab34a6e631cd Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 14 Jun 2024 22:23:12 +0600 Subject: [PATCH 140/261] refactor(blacklist): use drift sql db instead of hive --- lib/components/track_tile/track_options.dart | 29 +- lib/components/track_tile/track_tile.dart | 8 +- lib/models/database/database.dart | 10 +- lib/models/database/database.g.dart | 399 ++++++++++++++++++- lib/models/database/tables/blacklist.dart | 18 + lib/modules/artist/artist_card.dart | 6 +- lib/pages/artist/section/header.dart | 24 +- lib/pages/settings/blacklist.dart | 32 +- lib/provider/blacklist_provider.dart | 108 ++--- 9 files changed, 512 insertions(+), 122 deletions(-) create mode 100644 lib/models/database/tables/blacklist.dart diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index 89f6679d..fd3018ba 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -18,6 +18,7 @@ import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -170,11 +171,8 @@ class TrackOptions extends HookConsumerWidget { final favorites = useTrackToggleLike(track, ref); final isBlackListed = useMemoized( - () => blacklist.contains( - BlacklistedElement.track( - track.id!, - track.name!, - ), + () => blacklist.asData?.value.any( + (element) => element.elementId == track.id, ), [blacklist, track], ); @@ -258,13 +256,16 @@ class TrackOptions extends HookConsumerWidget { .removeTracks(playlistId ?? "", [track.id!]); break; case TrackOptionValue.blacklist: - if (isBlackListed) { - ref.read(blacklistProvider.notifier).remove( - BlacklistedElement.track(track.id!, track.name!), - ); + if (isBlackListed == null) break; + if (isBlackListed == true) { + await ref.read(blacklistProvider.notifier).remove(track.id!); } else { - ref.read(blacklistProvider.notifier).add( - BlacklistedElement.track(track.id!, track.name!), + await ref.read(blacklistProvider.notifier).add( + BlacklistTableCompanion.insert( + name: track.name!, + elementId: track.id!, + elementType: BlacklistedType.track, + ), ); } break; @@ -399,10 +400,10 @@ class TrackOptions extends HookConsumerWidget { PopSheetEntry( value: TrackOptionValue.blacklist, leading: const Icon(SpotubeIcons.playlistRemove), - iconColor: !isBlackListed ? Colors.red[400] : null, - textColor: !isBlackListed ? Colors.red[400] : null, + iconColor: isBlackListed != true ? Colors.red[400] : null, + textColor: isBlackListed != true ? Colors.red[400] : null, title: Text( - isBlackListed + isBlackListed == true ? context.l10n.remove_from_blacklist : context.l10n.add_to_blacklist, ), diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index 9ba87abe..e2e7e293 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -53,14 +53,10 @@ class TrackTile extends HookConsumerWidget { final theme = Theme.of(context); final blacklist = ref.watch(blacklistProvider); + final blacklistNotifier = ref.watch(blacklistProvider.notifier); final isBlackListed = useMemoized( - () => blacklist.contains( - BlacklistedElement.track( - track.id!, - track.name!, - ), - ), + () => blacklistNotifier.contains(track), [blacklist, track], ); diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index e7ac2558..ad09933d 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -19,12 +19,20 @@ part 'database.g.dart'; part 'tables/preferences.dart'; part 'tables/source_match.dart'; part 'tables/skip_segment.dart'; +part 'tables/blacklist.dart'; part 'typeconverters/color.dart'; part 'typeconverters/locale.dart'; part 'typeconverters/string_list.dart'; -@DriftDatabase(tables: [PreferencesTable, SourceMatchTable, SkipSegmentTable]) +@DriftDatabase( + tables: [ + PreferencesTable, + SourceMatchTable, + SkipSegmentTable, + BlacklistTable, + ], +) class AppDatabase extends _$AppDatabase { AppDatabase() : super(_openConnection()); diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index 9cc8a1c1..8c996d21 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -1789,6 +1789,266 @@ class SkipSegmentTableCompanion extends UpdateCompanion { } } +class $BlacklistTableTable extends BlacklistTable + with TableInfo<$BlacklistTableTable, BlacklistTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $BlacklistTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _elementTypeMeta = + const VerificationMeta('elementType'); + @override + late final GeneratedColumnWithTypeConverter + elementType = GeneratedColumn('element_type', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter( + $BlacklistTableTable.$converterelementType); + static const VerificationMeta _elementIdMeta = + const VerificationMeta('elementId'); + @override + late final GeneratedColumn elementId = GeneratedColumn( + 'element_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + @override + List get $columns => [id, name, elementType, elementId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'blacklist_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + context.handle(_elementTypeMeta, const VerificationResult.success()); + if (data.containsKey('element_id')) { + context.handle(_elementIdMeta, + elementId.isAcceptableOrUnknown(data['element_id']!, _elementIdMeta)); + } else if (isInserting) { + context.missing(_elementIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + BlacklistTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BlacklistTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + elementType: $BlacklistTableTable.$converterelementType.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}element_type'])!), + elementId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}element_id'])!, + ); + } + + @override + $BlacklistTableTable createAlias(String alias) { + return $BlacklistTableTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 + $converterelementType = + const EnumNameConverter(BlacklistedType.values); +} + +class BlacklistTableData extends DataClass + implements Insertable { + final int id; + final String name; + final BlacklistedType elementType; + final String elementId; + const BlacklistTableData( + {required this.id, + required this.name, + required this.elementType, + required this.elementId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + { + map['element_type'] = Variable( + $BlacklistTableTable.$converterelementType.toSql(elementType)); + } + map['element_id'] = Variable(elementId); + return map; + } + + BlacklistTableCompanion toCompanion(bool nullToAbsent) { + return BlacklistTableCompanion( + id: Value(id), + name: Value(name), + elementType: Value(elementType), + elementId: Value(elementId), + ); + } + + factory BlacklistTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BlacklistTableData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + elementType: $BlacklistTableTable.$converterelementType + .fromJson(serializer.fromJson(json['elementType'])), + elementId: serializer.fromJson(json['elementId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'elementType': serializer.toJson( + $BlacklistTableTable.$converterelementType.toJson(elementType)), + 'elementId': serializer.toJson(elementId), + }; + } + + BlacklistTableData copyWith( + {int? id, + String? name, + BlacklistedType? elementType, + String? elementId}) => + BlacklistTableData( + id: id ?? this.id, + name: name ?? this.name, + elementType: elementType ?? this.elementType, + elementId: elementId ?? this.elementId, + ); + @override + String toString() { + return (StringBuffer('BlacklistTableData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('elementType: $elementType, ') + ..write('elementId: $elementId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, elementType, elementId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BlacklistTableData && + other.id == this.id && + other.name == this.name && + other.elementType == this.elementType && + other.elementId == this.elementId); +} + +class BlacklistTableCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value elementType; + final Value elementId; + const BlacklistTableCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.elementType = const Value.absent(), + this.elementId = const Value.absent(), + }); + BlacklistTableCompanion.insert({ + this.id = const Value.absent(), + required String name, + required BlacklistedType elementType, + required String elementId, + }) : name = Value(name), + elementType = Value(elementType), + elementId = Value(elementId); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? elementType, + Expression? elementId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (elementType != null) 'element_type': elementType, + if (elementId != null) 'element_id': elementId, + }); + } + + BlacklistTableCompanion copyWith( + {Value? id, + Value? name, + Value? elementType, + Value? elementId}) { + return BlacklistTableCompanion( + id: id ?? this.id, + name: name ?? this.name, + elementType: elementType ?? this.elementType, + elementId: elementId ?? this.elementId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (elementType.present) { + map['element_type'] = Variable( + $BlacklistTableTable.$converterelementType.toSql(elementType.value)); + } + if (elementId.present) { + map['element_id'] = Variable(elementId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BlacklistTableCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('elementType: $elementType, ') + ..write('elementId: $elementId') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); _$AppDatabaseManager get managers => _$AppDatabaseManager(this); @@ -1798,14 +2058,23 @@ abstract class _$AppDatabase extends GeneratedDatabase { $SourceMatchTableTable(this); late final $SkipSegmentTableTable skipSegmentTable = $SkipSegmentTableTable(this); + late final $BlacklistTableTable blacklistTable = $BlacklistTableTable(this); late final Index uniqTrackMatch = Index('uniq_track_match', 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); + late final Index uniqueBlacklist = Index('unique_blacklist', + 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => - [preferencesTable, sourceMatchTable, skipSegmentTable, uniqTrackMatch]; + List get allSchemaEntities => [ + preferencesTable, + sourceMatchTable, + skipSegmentTable, + blacklistTable, + uniqTrackMatch, + uniqueBlacklist + ]; } typedef $$PreferencesTableTableInsertCompanionBuilder @@ -2571,6 +2840,130 @@ class $$SkipSegmentTableTableOrderingComposer ColumnOrderings(column, joinBuilders: joinBuilders)); } +typedef $$BlacklistTableTableInsertCompanionBuilder = BlacklistTableCompanion + Function({ + Value id, + required String name, + required BlacklistedType elementType, + required String elementId, +}); +typedef $$BlacklistTableTableUpdateCompanionBuilder = BlacklistTableCompanion + Function({ + Value id, + Value name, + Value elementType, + Value elementId, +}); + +class $$BlacklistTableTableTableManager extends RootTableManager< + _$AppDatabase, + $BlacklistTableTable, + BlacklistTableData, + $$BlacklistTableTableFilterComposer, + $$BlacklistTableTableOrderingComposer, + $$BlacklistTableTableProcessedTableManager, + $$BlacklistTableTableInsertCompanionBuilder, + $$BlacklistTableTableUpdateCompanionBuilder> { + $$BlacklistTableTableTableManager( + _$AppDatabase db, $BlacklistTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$BlacklistTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$BlacklistTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$BlacklistTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value elementType = const Value.absent(), + Value elementId = const Value.absent(), + }) => + BlacklistTableCompanion( + id: id, + name: name, + elementType: elementType, + elementId: elementId, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required String name, + required BlacklistedType elementType, + required String elementId, + }) => + BlacklistTableCompanion.insert( + id: id, + name: name, + elementType: elementType, + elementId: elementId, + ), + )); +} + +class $$BlacklistTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $BlacklistTableTable, + BlacklistTableData, + $$BlacklistTableTableFilterComposer, + $$BlacklistTableTableOrderingComposer, + $$BlacklistTableTableProcessedTableManager, + $$BlacklistTableTableInsertCompanionBuilder, + $$BlacklistTableTableUpdateCompanionBuilder> { + $$BlacklistTableTableProcessedTableManager(super.$state); +} + +class $$BlacklistTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $BlacklistTableTable> { + $$BlacklistTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get name => $state.composableBuilder( + column: $state.table.name, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get elementType => $state.composableBuilder( + column: $state.table.elementType, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get elementId => $state.composableBuilder( + column: $state.table.elementId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$BlacklistTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $BlacklistTableTable> { + $$BlacklistTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get name => $state.composableBuilder( + column: $state.table.name, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get elementType => $state.composableBuilder( + column: $state.table.elementType, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get elementId => $state.composableBuilder( + column: $state.table.elementId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + class _$AppDatabaseManager { final _$AppDatabase _db; _$AppDatabaseManager(this._db); @@ -2580,4 +2973,6 @@ class _$AppDatabaseManager { $$SourceMatchTableTableTableManager(_db, _db.sourceMatchTable); $$SkipSegmentTableTableTableManager get skipSegmentTable => $$SkipSegmentTableTableTableManager(_db, _db.skipSegmentTable); + $$BlacklistTableTableTableManager get blacklistTable => + $$BlacklistTableTableTableManager(_db, _db.blacklistTable); } diff --git a/lib/models/database/tables/blacklist.dart b/lib/models/database/tables/blacklist.dart new file mode 100644 index 00000000..8a8d9dee --- /dev/null +++ b/lib/models/database/tables/blacklist.dart @@ -0,0 +1,18 @@ +part of '../database.dart'; + +enum BlacklistedType { + artist, + track; +} + +@TableIndex( + name: "unique_blacklist", + unique: true, + columns: {#elementType, #elementId}, +) +class BlacklistTable extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get name => text()(); + TextColumn get elementType => textEnum()(); + TextColumn get elementId => text()(); +} diff --git a/lib/modules/artist/artist_card.dart b/lib/modules/artist/artist_card.dart index c1404e42..896271f2 100644 --- a/lib/modules/artist/artist_card.dart +++ b/lib/modules/artist/artist_card.dart @@ -27,8 +27,8 @@ class ArtistCard extends HookConsumerWidget { ); final isBlackListed = ref.watch( blacklistProvider.select( - (blacklist) => blacklist.contains( - BlacklistedElement.artist(artist.id!, artist.name!), + (blacklist) => blacklist.asData?.value.any( + (element) => element.elementId == artist.id, ), ), ); @@ -55,7 +55,7 @@ class ArtistCard extends HookConsumerWidget { elevation: 3, shape: RoundedRectangleBorder( borderRadius: radius, - side: isBlackListed + side: isBlackListed == true ? const BorderSide( color: Colors.red, width: 2, diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 7ca8964d..a30535dd 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -10,6 +10,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -39,10 +40,9 @@ class ArtistPageHeader extends HookConsumerWidget { ); final auth = ref.watch(authenticationProvider); - final blacklist = ref.watch(blacklistProvider); - final isBlackListed = blacklist.contains( - BlacklistedElement.artist(artistId, artist.name!), - ); + ref.watch(blacklistProvider); + final blacklistNotifier = ref.watch(blacklistProvider.notifier); + final isBlackListed = blacklistNotifier.containsArtist(artist); final image = artist.images.asUrlString( placeholder: ImagePlaceholder.artist, @@ -187,14 +187,16 @@ class ArtistPageHeader extends HookConsumerWidget { ), onPressed: () async { if (isBlackListed) { - ref.read(blacklistProvider.notifier).remove( - BlacklistedElement.artist( - artist.id!, artist.name!), - ); + await ref + .read(blacklistProvider.notifier) + .remove(artist.id!); } else { - ref.read(blacklistProvider.notifier).add( - BlacklistedElement.artist( - artist.id!, artist.name!), + await ref.read(blacklistProvider.notifier).add( + BlacklistTableCompanion.insert( + name: artist.name!, + elementId: artist.id!, + elementType: BlacklistedType.artist, + ), ); } }, diff --git a/lib/pages/settings/blacklist.dart b/lib/pages/settings/blacklist.dart index b5e10821..1f018dab 100644 --- a/lib/pages/settings/blacklist.dart +++ b/lib/pages/settings/blacklist.dart @@ -24,19 +24,21 @@ class BlackListPage extends HookConsumerWidget { final filteredBlacklist = useMemoized( () { if (searchText.value.isEmpty) { - return blacklist; + return blacklist.asData?.value ?? []; } - return blacklist - .map( - (e) => ( - weightedRatio("${e.name} ${e.type.name}", searchText.value), - e, - ), - ) - .sorted((a, b) => b.$1.compareTo(a.$1)) - .where((e) => e.$1 > 50) - .map((e) => e.$2) - .toList(); + return blacklist.asData?.value + .map( + (e) => ( + weightedRatio( + "${e.name} ${e.elementType.name}", searchText.value), + e, + ), + ) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList() ?? + []; }, [blacklist, searchText.value], ); @@ -70,14 +72,14 @@ class BlackListPage extends HookConsumerWidget { final item = filteredBlacklist.elementAt(index); return ListTile( leading: Text("${index + 1}."), - title: Text("${item.name} (${item.type.name})"), - subtitle: Text(item.id), + title: Text("${item.name} (${item.elementType.name})"), + subtitle: Text(item.elementId), trailing: IconButton( icon: Icon(SpotubeIcons.trash, color: Colors.red[400]), onPressed: () { ref .read(blacklistProvider.notifier) - .remove(filteredBlacklist.elementAt(index)); + .remove(filteredBlacklist.elementAt(index).elementId); }, ), ); diff --git a/lib/provider/blacklist_provider.dart b/lib/provider/blacklist_provider.dart index 4f488112..a51d399f 100644 --- a/lib/provider/blacklist_provider.dart +++ b/lib/provider/blacklist_provider.dart @@ -2,69 +2,59 @@ import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/current_playlist.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; - -enum BlacklistedType { - artist, - track; - - static BlacklistedType fromName(String name) => - BlacklistedType.values.firstWhere((e) => e.name == name); -} - -class BlacklistedElement { - final String id; - final String name; - final BlacklistedType type; - - BlacklistedElement.artist(this.id, this.name) : type = BlacklistedType.artist; - - BlacklistedElement.track(this.id, this.name) : type = BlacklistedType.track; - - BlacklistedElement.fromJson(Map json) - : id = json['id'], - name = json['name'], - type = BlacklistedType.fromName(json['type']); - - Map toJson() => {'id': id, 'type': type.name, 'name': name}; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +class BlackListNotifier extends AsyncNotifier> { @override - operator ==(other) => - other is BlacklistedElement && - other.id == id && - other.type == type && - other.name == name; + build() async { + final database = ref.watch(databaseProvider); - @override - int get hashCode => id.hashCode ^ type.hashCode ^ name.hashCode; -} + final subscription = database + .select(database.blacklistTable) + .watch() + .listen((event) => state = AsyncData(event)); -class BlackListNotifier - extends PersistedStateNotifier> { - BlackListNotifier() : super({}, "blacklist"); + ref.onDispose(() { + subscription.cancel(); + }); - void add(BlacklistedElement element) { - state = state.union({element}); + return await database.select(database.blacklistTable).get(); } - void remove(BlacklistedElement element) { - state = state.difference({element}); + AppDatabase get _database => ref.read(databaseProvider); + + Future add(BlacklistTableCompanion element) async { + _database.into(_database.blacklistTable).insert(element); + } + + Future remove(String elementId) async { + await (_database.delete(_database.blacklistTable) + ..where((tbl) => tbl.elementId.equals(elementId))) + .go(); } bool contains(TrackSimple track) { final containsTrack = - state.contains(BlacklistedElement.track(track.id!, track.name!)); + state.asData?.value.any((element) => element.elementId == track.id) ?? + false; final containsTrackArtists = track.artists?.any( - (artist) => state.contains( - BlacklistedElement.artist(artist.id!, artist.name ?? "Spotify"), - ), + (artist) => + state.asData?.value.any((el) => el.elementId == artist.id) ?? + false, ) ?? false; return containsTrack || containsTrackArtists; } + bool containsArtist(ArtistSimple artist) { + return state.asData?.value + .any((element) => element.elementId == artist.id) ?? + false; + } + /// Filters the non blacklisted tracks from the given [tracks] Iterable filter(Iterable tracks) { return tracks.whereNot(contains).toList(); @@ -75,34 +65,12 @@ class BlackListNotifier id: playlist.id, name: playlist.name, thumbnail: playlist.thumbnail, - tracks: playlist.tracks.where( - (track) { - return !state - .contains(BlacklistedElement.track(track.id!, track.name!)) && - !(track.artists ?? []).any( - (artist) => state.contains( - BlacklistedElement.artist(artist.id!, artist.name!), - ), - ); - }, - ).toList(), + tracks: playlist.tracks.where((track) => !contains(track)).toList(), ); } - - @override - Set fromJson(Map json) { - return json['blacklist'] - .map((e) => BlacklistedElement.fromJson(e)) - .toSet(); - } - - @override - Map toJson() { - return {'blacklist': state.map((e) => e.toJson()).toList()}; - } } final blacklistProvider = - StateNotifierProvider>((ref) { - return BlackListNotifier(); -}); + AsyncNotifierProvider>( + () => BlackListNotifier(), +); From a799ca55bcb8833c1a2e89078df0460229ef5053 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 16 Jun 2024 20:58:54 +0600 Subject: [PATCH 141/261] chore: add encrypted text column support --- lib/main.dart | 2 + lib/models/database/database.dart | 6 ++- .../typeconverters/encrypted_text.dart | 39 +++++++++++++++++ lib/services/kv_store/encrypted_kv_store.dart | 43 +++++++++++++++++++ lib/services/kv_store/kv_store.dart | 35 +++++++++++++++ pubspec.lock | 24 +++++++++++ pubspec.yaml | 1 + 7 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 lib/models/database/typeconverters/encrypted_text.dart create mode 100644 lib/services/kv_store/encrypted_kv_store.dart diff --git a/lib/main.dart b/lib/main.dart index bdccadd4..09db495c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,6 +27,7 @@ import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/cli/cli.dart'; +import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/wm_tools/wm_tools.dart'; @@ -76,6 +77,7 @@ Future main(List rawArgs) async { } await KVStoreService.initialize(); + await EncryptedKvStoreService.initialize(); final hiveCacheDir = kIsWeb ? null : (await getApplicationSupportDirectory()).path; diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index ad09933d..ac0223fd 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -4,11 +4,14 @@ import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart'; +import 'package:encrypt/encrypt.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/sourced_track/enums.dart'; -import 'package:flutter/material.dart' hide Table; +import 'package:flutter/material.dart' hide Table, Key; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:drift/native.dart'; import 'package:sqlite3/sqlite3.dart'; @@ -24,6 +27,7 @@ part 'tables/blacklist.dart'; part 'typeconverters/color.dart'; part 'typeconverters/locale.dart'; part 'typeconverters/string_list.dart'; +part 'typeconverters/encrypted_text.dart'; @DriftDatabase( tables: [ diff --git a/lib/models/database/typeconverters/encrypted_text.dart b/lib/models/database/typeconverters/encrypted_text.dart new file mode 100644 index 00000000..27921788 --- /dev/null +++ b/lib/models/database/typeconverters/encrypted_text.dart @@ -0,0 +1,39 @@ +part of '../database.dart'; + +class DecryptedText { + final String value; + const DecryptedText(this.value); + + static Encrypter? _encrypter; + + factory DecryptedText.decrypted(String value) { + _encrypter ??= Encrypter( + Salsa20( + Key.fromUtf8(EncryptedKvStoreService.encryptionKeySync), + ), + ); + + return DecryptedText( + _encrypter!.decrypt( + Encrypted.fromBase64(value), + iv: KVStoreService.ivKey, + ), + ); + } + + String encrypt() { + return _encrypter!.encrypt(value, iv: KVStoreService.ivKey).base64; + } +} + +class EncryptedTextConverter extends TypeConverter { + @override + DecryptedText fromSql(String fromDb) { + return DecryptedText.decrypted(fromDb); + } + + @override + String toSql(DecryptedText value) { + return value.encrypt(); + } +} diff --git a/lib/services/kv_store/encrypted_kv_store.dart b/lib/services/kv_store/encrypted_kv_store.dart new file mode 100644 index 00000000..d8f69690 --- /dev/null +++ b/lib/services/kv_store/encrypted_kv_store.dart @@ -0,0 +1,43 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:uuid/uuid.dart'; + +abstract class EncryptedKvStoreService { + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, + ), + ); + + static late final String _encryptionKeySync; + + static Future initialize() async { + _encryptionKeySync = await encryptionKey; + } + + static String get encryptionKeySync => _encryptionKeySync; + + static Future get encryptionKey async { + try { + final value = await _storage.read(key: 'encryption'); + final key = const Uuid().v4(); + + if (value == null) { + await setEncryptionKey(key); + return key; + } + + return value; + } catch (e) { + return KVStoreService.encryptionKey; + } + } + + static Future setEncryptionKey(String key) async { + try { + await _storage.write(key: 'encryption', value: key); + } catch (e) { + await KVStoreService.setEncryptionKey(key); + } + } +} diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index ae62a055..6b19c032 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'package:encrypt/encrypt.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/services/wm_tools/wm_tools.dart'; +import 'package:uuid/uuid.dart'; abstract class KVStoreService { static SharedPreferences? _sharedPreferences; @@ -43,4 +45,37 @@ abstract class KVStoreService { value.toJson(), ), ); + + static String get encryptionKey { + final value = sharedPreferences.getString('encryption'); + + final key = const Uuid().v4(); + if (value == null) { + setEncryptionKey(key); + return key; + } + + return value; + } + + static Future setEncryptionKey(String key) async { + await sharedPreferences.setString('encryption', key); + } + + static IV get ivKey { + final iv = sharedPreferences.getString('iv'); + final value = IV.fromSecureRandom(8); + + if (iv == null) { + setIVKey(value); + + return value; + } + + return IV.fromBase64(iv); + } + + static Future setIVKey(IV iv) async { + await sharedPreferences.setString('iv', iv.base64); + } } diff --git a/pubspec.lock b/pubspec.lock index c5871a2a..70b0655c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda" + url: "https://pub.dev" + source: hosted + version: "1.5.3" async: dependency: "direct main" description: @@ -539,6 +547,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.13" + encrypt: + dependency: "direct main" + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" envied: dependency: "direct main" description: @@ -1670,6 +1686,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ddace46e..a923f5a3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -124,6 +124,7 @@ dependencies: drift: ^2.18.0 sqlite3_flutter_libs: ^0.5.23 sqlite3: ^2.4.3 + encrypt: ^5.0.3 dev_dependencies: build_runner: ^2.4.11 From d18f74fd65486f25319d5804a191616a9220d7da Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 16 Jun 2024 22:33:23 +0600 Subject: [PATCH 142/261] refactor: use drift db based authentication --- lib/collections/routes.dart | 8 +- .../fallbacks/anonymous_fallback.dart | 2 +- lib/components/heart_button/heart_button.dart | 4 +- lib/components/track_tile/track_options.dart | 2 +- .../sections/header/header_actions.dart | 2 +- .../configurators/use_endless_playback.dart | 4 +- lib/models/database/database.dart | 14 +- lib/models/database/database.g.dart | 2051 ++++++++++------- .../database/tables/authentication.dart | 8 + .../typeconverters/encrypted_text.dart | 5 + .../database/typeconverters/string_list.dart | 2 +- lib/modules/desktop_login/login_form.dart | 7 +- lib/modules/home/sections/friends.dart | 4 +- lib/modules/home/sections/new_releases.dart | 4 +- .../local_folder/local_folder_item.dart | 2 +- lib/modules/library/user_albums.dart | 4 +- lib/modules/library/user_artists.dart | 4 +- lib/modules/library/user_playlists.dart | 4 +- lib/modules/player/player.dart | 2 +- lib/modules/player/player_actions.dart | 2 +- lib/modules/root/bottom_player.dart | 2 +- lib/modules/root/sidebar.dart | 4 +- lib/pages/artist/section/header.dart | 2 +- lib/pages/desktop_login/login_tutorial.dart | 9 +- lib/pages/lyrics/lyrics.dart | 4 +- lib/pages/lyrics/mini_lyrics.dart | 4 +- lib/pages/mobile_login/mobile_login.dart | 6 +- lib/pages/search/search.dart | 7 +- lib/pages/settings/sections/accounts.dart | 6 +- .../authentication.dart} | 190 +- .../custom_spotify_endpoint_provider.dart | 4 +- lib/provider/spotify/views/home.dart | 4 +- lib/provider/spotify/views/home_section.dart | 4 +- lib/provider/spotify_provider.dart | 6 +- .../user_preferences_provider.dart | 7 +- lib/services/kv_store/encrypted_kv_store.dart | 18 +- 36 files changed, 1408 insertions(+), 1004 deletions(-) create mode 100644 lib/models/database/tables/authentication.dart rename lib/provider/{authentication_provider.dart => authentication/authentication.dart} (51%) diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index b9e06c61..b3cba581 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -32,7 +32,7 @@ import 'package:spotube/pages/stats/playlists/playlists.dart'; import 'package:spotube/pages/stats/stats.dart'; import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/pages/track/track.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/components/spotube_page_route.dart'; @@ -59,11 +59,9 @@ final routerProvider = Provider((ref) { path: "/", name: HomePage.name, redirect: (context, state) async { - final authNotifier = ref.read(authenticationProvider.notifier); - final json = await authNotifier.box.get(authNotifier.cacheKey); + final auth = await ref.read(authenticationProvider.future); - if (json?["cookie"] == null && - !KVStoreService.doneGettingStarted) { + if (auth == null && !KVStoreService.doneGettingStarted) { return "/getting-started"; } diff --git a/lib/components/fallbacks/anonymous_fallback.dart b/lib/components/fallbacks/anonymous_fallback.dart index 5ced6bb6..799297e3 100644 --- a/lib/components/fallbacks/anonymous_fallback.dart +++ b/lib/components/fallbacks/anonymous_fallback.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/settings/settings.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/service_utils.dart'; class AnonymousFallback extends ConsumerWidget { diff --git a/lib/components/heart_button/heart_button.dart b/lib/components/heart_button/heart_button.dart index 8222b8e6..fa4318cc 100644 --- a/lib/components/heart_button/heart_button.dart +++ b/lib/components/heart_button/heart_button.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/heart_button/use_track_toggle_like.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HeartButton extends HookConsumerWidget { @@ -26,7 +26,7 @@ class HeartButton extends HookConsumerWidget { Widget build(BuildContext context, ref) { final auth = ref.watch(authenticationProvider); - if (auth == null) return const SizedBox.shrink(); + if (auth.asData?.value == null) return const SizedBox.shrink(); return IconButton( tooltip: tooltip, diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index fd3018ba..d54a0c15 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -20,7 +20,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; diff --git a/lib/components/tracks_view/sections/header/header_actions.dart b/lib/components/tracks_view/sections/header/header_actions.dart index 3e0c4cc1..f20cd553 100644 --- a/lib/components/tracks_view/sections/header/header_actions.dart +++ b/lib/components/tracks_view/sections/header/header_actions.dart @@ -9,7 +9,7 @@ import 'package:spotube/components/heart_button/heart_button.dart'; import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart index 97eb3f48..9b90b23d 100644 --- a/lib/hooks/configurators/use_endless_playback.dart +++ b/lib/hooks/configurators/use_endless_playback.dart @@ -2,7 +2,7 @@ import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -18,7 +18,7 @@ void useEndlessPlayback(WidgetRef ref) { useEffect( () { - if (!endlessPlayback || auth == null) return null; + if (!endlessPlayback || auth.asData?.value == null) return null; void listener(int index) async { try { diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index ac0223fd..56f72ee7 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -19,10 +19,11 @@ import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; part 'database.g.dart'; -part 'tables/preferences.dart'; -part 'tables/source_match.dart'; -part 'tables/skip_segment.dart'; +part 'tables/authentication.dart'; part 'tables/blacklist.dart'; +part 'tables/preferences.dart'; +part 'tables/skip_segment.dart'; +part 'tables/source_match.dart'; part 'typeconverters/color.dart'; part 'typeconverters/locale.dart'; @@ -31,10 +32,11 @@ part 'typeconverters/encrypted_text.dart'; @DriftDatabase( tables: [ - PreferencesTable, - SourceMatchTable, - SkipSegmentTable, + AuthenticationTable, BlacklistTable, + PreferencesTable, + SkipSegmentTable, + SourceMatchTable, ], ) class AppDatabase extends _$AppDatabase { diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index 8c996d21..0ac7005e 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -3,6 +3,533 @@ part of 'database.dart'; // ignore_for_file: type=lint +class $AuthenticationTableTable extends AuthenticationTable + with TableInfo<$AuthenticationTableTable, AuthenticationTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AuthenticationTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _cookieMeta = const VerificationMeta('cookie'); + @override + late final GeneratedColumnWithTypeConverter cookie = + GeneratedColumn('cookie', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter( + $AuthenticationTableTable.$convertercookie); + static const VerificationMeta _accessTokenMeta = + const VerificationMeta('accessToken'); + @override + late final GeneratedColumnWithTypeConverter + accessToken = GeneratedColumn('access_token', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter( + $AuthenticationTableTable.$converteraccessToken); + static const VerificationMeta _expirationMeta = + const VerificationMeta('expiration'); + @override + late final GeneratedColumn expiration = GeneratedColumn( + 'expiration', aliasedName, false, + type: DriftSqlType.dateTime, requiredDuringInsert: true); + @override + List get $columns => [id, cookie, accessToken, expiration]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'authentication_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + context.handle(_cookieMeta, const VerificationResult.success()); + context.handle(_accessTokenMeta, const VerificationResult.success()); + if (data.containsKey('expiration')) { + context.handle( + _expirationMeta, + expiration.isAcceptableOrUnknown( + data['expiration']!, _expirationMeta)); + } else if (isInserting) { + context.missing(_expirationMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + AuthenticationTableData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AuthenticationTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + cookie: $AuthenticationTableTable.$convertercookie.fromSql( + attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}cookie'])!), + accessToken: $AuthenticationTableTable.$converteraccessToken.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}access_token'])!), + expiration: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}expiration'])!, + ); + } + + @override + $AuthenticationTableTable createAlias(String alias) { + return $AuthenticationTableTable(attachedDatabase, alias); + } + + static TypeConverter $convertercookie = + EncryptedTextConverter(); + static TypeConverter $converteraccessToken = + EncryptedTextConverter(); +} + +class AuthenticationTableData extends DataClass + implements Insertable { + final int id; + final DecryptedText cookie; + final DecryptedText accessToken; + final DateTime expiration; + const AuthenticationTableData( + {required this.id, + required this.cookie, + required this.accessToken, + required this.expiration}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + { + map['cookie'] = Variable( + $AuthenticationTableTable.$convertercookie.toSql(cookie)); + } + { + map['access_token'] = Variable( + $AuthenticationTableTable.$converteraccessToken.toSql(accessToken)); + } + map['expiration'] = Variable(expiration); + return map; + } + + AuthenticationTableCompanion toCompanion(bool nullToAbsent) { + return AuthenticationTableCompanion( + id: Value(id), + cookie: Value(cookie), + accessToken: Value(accessToken), + expiration: Value(expiration), + ); + } + + factory AuthenticationTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AuthenticationTableData( + id: serializer.fromJson(json['id']), + cookie: serializer.fromJson(json['cookie']), + accessToken: serializer.fromJson(json['accessToken']), + expiration: serializer.fromJson(json['expiration']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'cookie': serializer.toJson(cookie), + 'accessToken': serializer.toJson(accessToken), + 'expiration': serializer.toJson(expiration), + }; + } + + AuthenticationTableData copyWith( + {int? id, + DecryptedText? cookie, + DecryptedText? accessToken, + DateTime? expiration}) => + AuthenticationTableData( + id: id ?? this.id, + cookie: cookie ?? this.cookie, + accessToken: accessToken ?? this.accessToken, + expiration: expiration ?? this.expiration, + ); + @override + String toString() { + return (StringBuffer('AuthenticationTableData(') + ..write('id: $id, ') + ..write('cookie: $cookie, ') + ..write('accessToken: $accessToken, ') + ..write('expiration: $expiration') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, cookie, accessToken, expiration); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AuthenticationTableData && + other.id == this.id && + other.cookie == this.cookie && + other.accessToken == this.accessToken && + other.expiration == this.expiration); +} + +class AuthenticationTableCompanion + extends UpdateCompanion { + final Value id; + final Value cookie; + final Value accessToken; + final Value expiration; + const AuthenticationTableCompanion({ + this.id = const Value.absent(), + this.cookie = const Value.absent(), + this.accessToken = const Value.absent(), + this.expiration = const Value.absent(), + }); + AuthenticationTableCompanion.insert({ + this.id = const Value.absent(), + required DecryptedText cookie, + required DecryptedText accessToken, + required DateTime expiration, + }) : cookie = Value(cookie), + accessToken = Value(accessToken), + expiration = Value(expiration); + static Insertable custom({ + Expression? id, + Expression? cookie, + Expression? accessToken, + Expression? expiration, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (cookie != null) 'cookie': cookie, + if (accessToken != null) 'access_token': accessToken, + if (expiration != null) 'expiration': expiration, + }); + } + + AuthenticationTableCompanion copyWith( + {Value? id, + Value? cookie, + Value? accessToken, + Value? expiration}) { + return AuthenticationTableCompanion( + id: id ?? this.id, + cookie: cookie ?? this.cookie, + accessToken: accessToken ?? this.accessToken, + expiration: expiration ?? this.expiration, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (cookie.present) { + map['cookie'] = Variable( + $AuthenticationTableTable.$convertercookie.toSql(cookie.value)); + } + if (accessToken.present) { + map['access_token'] = Variable($AuthenticationTableTable + .$converteraccessToken + .toSql(accessToken.value)); + } + if (expiration.present) { + map['expiration'] = Variable(expiration.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AuthenticationTableCompanion(') + ..write('id: $id, ') + ..write('cookie: $cookie, ') + ..write('accessToken: $accessToken, ') + ..write('expiration: $expiration') + ..write(')')) + .toString(); + } +} + +class $BlacklistTableTable extends BlacklistTable + with TableInfo<$BlacklistTableTable, BlacklistTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $BlacklistTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _nameMeta = const VerificationMeta('name'); + @override + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _elementTypeMeta = + const VerificationMeta('elementType'); + @override + late final GeneratedColumnWithTypeConverter + elementType = GeneratedColumn('element_type', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter( + $BlacklistTableTable.$converterelementType); + static const VerificationMeta _elementIdMeta = + const VerificationMeta('elementId'); + @override + late final GeneratedColumn elementId = GeneratedColumn( + 'element_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + @override + List get $columns => [id, name, elementType, elementId]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'blacklist_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('name')) { + context.handle( + _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + } else if (isInserting) { + context.missing(_nameMeta); + } + context.handle(_elementTypeMeta, const VerificationResult.success()); + if (data.containsKey('element_id')) { + context.handle(_elementIdMeta, + elementId.isAcceptableOrUnknown(data['element_id']!, _elementIdMeta)); + } else if (isInserting) { + context.missing(_elementIdMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + BlacklistTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BlacklistTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name'])!, + elementType: $BlacklistTableTable.$converterelementType.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}element_type'])!), + elementId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}element_id'])!, + ); + } + + @override + $BlacklistTableTable createAlias(String alias) { + return $BlacklistTableTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 + $converterelementType = + const EnumNameConverter(BlacklistedType.values); +} + +class BlacklistTableData extends DataClass + implements Insertable { + final int id; + final String name; + final BlacklistedType elementType; + final String elementId; + const BlacklistTableData( + {required this.id, + required this.name, + required this.elementType, + required this.elementId}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['name'] = Variable(name); + { + map['element_type'] = Variable( + $BlacklistTableTable.$converterelementType.toSql(elementType)); + } + map['element_id'] = Variable(elementId); + return map; + } + + BlacklistTableCompanion toCompanion(bool nullToAbsent) { + return BlacklistTableCompanion( + id: Value(id), + name: Value(name), + elementType: Value(elementType), + elementId: Value(elementId), + ); + } + + factory BlacklistTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BlacklistTableData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + elementType: $BlacklistTableTable.$converterelementType + .fromJson(serializer.fromJson(json['elementType'])), + elementId: serializer.fromJson(json['elementId']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'elementType': serializer.toJson( + $BlacklistTableTable.$converterelementType.toJson(elementType)), + 'elementId': serializer.toJson(elementId), + }; + } + + BlacklistTableData copyWith( + {int? id, + String? name, + BlacklistedType? elementType, + String? elementId}) => + BlacklistTableData( + id: id ?? this.id, + name: name ?? this.name, + elementType: elementType ?? this.elementType, + elementId: elementId ?? this.elementId, + ); + @override + String toString() { + return (StringBuffer('BlacklistTableData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('elementType: $elementType, ') + ..write('elementId: $elementId') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, elementType, elementId); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BlacklistTableData && + other.id == this.id && + other.name == this.name && + other.elementType == this.elementType && + other.elementId == this.elementId); +} + +class BlacklistTableCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value elementType; + final Value elementId; + const BlacklistTableCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.elementType = const Value.absent(), + this.elementId = const Value.absent(), + }); + BlacklistTableCompanion.insert({ + this.id = const Value.absent(), + required String name, + required BlacklistedType elementType, + required String elementId, + }) : name = Value(name), + elementType = Value(elementType), + elementId = Value(elementId); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? elementType, + Expression? elementId, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (elementType != null) 'element_type': elementType, + if (elementId != null) 'element_id': elementId, + }); + } + + BlacklistTableCompanion copyWith( + {Value? id, + Value? name, + Value? elementType, + Value? elementId}) { + return BlacklistTableCompanion( + id: id ?? this.id, + name: name ?? this.name, + elementType: elementType ?? this.elementType, + elementId: elementId ?? this.elementId, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (elementType.present) { + map['element_type'] = Variable( + $BlacklistTableTable.$converterelementType.toSql(elementType.value)); + } + if (elementId.present) { + map['element_id'] = Variable(elementId.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BlacklistTableCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('elementType: $elementType, ') + ..write('elementId: $elementId') + ..write(')')) + .toString(); + } +} + class $PreferencesTableTable extends PreferencesTable with TableInfo<$PreferencesTableTable, PreferencesTableData> { @override @@ -1204,6 +1731,293 @@ class PreferencesTableCompanion extends UpdateCompanion { } } +class $SkipSegmentTableTable extends SkipSegmentTable + with TableInfo<$SkipSegmentTableTable, SkipSegmentTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SkipSegmentTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _startMeta = const VerificationMeta('start'); + @override + late final GeneratedColumn start = GeneratedColumn( + 'start', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _endMeta = const VerificationMeta('end'); + @override + late final GeneratedColumn end = GeneratedColumn( + 'end', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + static const VerificationMeta _trackIdMeta = + const VerificationMeta('trackId'); + @override + late final GeneratedColumn trackId = GeneratedColumn( + 'track_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime); + @override + List get $columns => [id, start, end, trackId, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'skip_segment_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('start')) { + context.handle( + _startMeta, start.isAcceptableOrUnknown(data['start']!, _startMeta)); + } else if (isInserting) { + context.missing(_startMeta); + } + if (data.containsKey('end')) { + context.handle( + _endMeta, end.isAcceptableOrUnknown(data['end']!, _endMeta)); + } else if (isInserting) { + context.missing(_endMeta); + } + if (data.containsKey('track_id')) { + context.handle(_trackIdMeta, + trackId.isAcceptableOrUnknown(data['track_id']!, _trackIdMeta)); + } else if (isInserting) { + context.missing(_trackIdMeta); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + SkipSegmentTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SkipSegmentTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + start: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}start'])!, + end: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}end'])!, + trackId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}track_id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + ); + } + + @override + $SkipSegmentTableTable createAlias(String alias) { + return $SkipSegmentTableTable(attachedDatabase, alias); + } +} + +class SkipSegmentTableData extends DataClass + implements Insertable { + final int id; + final int start; + final int end; + final String trackId; + final DateTime createdAt; + const SkipSegmentTableData( + {required this.id, + required this.start, + required this.end, + required this.trackId, + required this.createdAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['start'] = Variable(start); + map['end'] = Variable(end); + map['track_id'] = Variable(trackId); + map['created_at'] = Variable(createdAt); + return map; + } + + SkipSegmentTableCompanion toCompanion(bool nullToAbsent) { + return SkipSegmentTableCompanion( + id: Value(id), + start: Value(start), + end: Value(end), + trackId: Value(trackId), + createdAt: Value(createdAt), + ); + } + + factory SkipSegmentTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SkipSegmentTableData( + id: serializer.fromJson(json['id']), + start: serializer.fromJson(json['start']), + end: serializer.fromJson(json['end']), + trackId: serializer.fromJson(json['trackId']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'start': serializer.toJson(start), + 'end': serializer.toJson(end), + 'trackId': serializer.toJson(trackId), + 'createdAt': serializer.toJson(createdAt), + }; + } + + SkipSegmentTableData copyWith( + {int? id, + int? start, + int? end, + String? trackId, + DateTime? createdAt}) => + SkipSegmentTableData( + id: id ?? this.id, + start: start ?? this.start, + end: end ?? this.end, + trackId: trackId ?? this.trackId, + createdAt: createdAt ?? this.createdAt, + ); + @override + String toString() { + return (StringBuffer('SkipSegmentTableData(') + ..write('id: $id, ') + ..write('start: $start, ') + ..write('end: $end, ') + ..write('trackId: $trackId, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, start, end, trackId, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SkipSegmentTableData && + other.id == this.id && + other.start == this.start && + other.end == this.end && + other.trackId == this.trackId && + other.createdAt == this.createdAt); +} + +class SkipSegmentTableCompanion extends UpdateCompanion { + final Value id; + final Value start; + final Value end; + final Value trackId; + final Value createdAt; + const SkipSegmentTableCompanion({ + this.id = const Value.absent(), + this.start = const Value.absent(), + this.end = const Value.absent(), + this.trackId = const Value.absent(), + this.createdAt = const Value.absent(), + }); + SkipSegmentTableCompanion.insert({ + this.id = const Value.absent(), + required int start, + required int end, + required String trackId, + this.createdAt = const Value.absent(), + }) : start = Value(start), + end = Value(end), + trackId = Value(trackId); + static Insertable custom({ + Expression? id, + Expression? start, + Expression? end, + Expression? trackId, + Expression? createdAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (start != null) 'start': start, + if (end != null) 'end': end, + if (trackId != null) 'track_id': trackId, + if (createdAt != null) 'created_at': createdAt, + }); + } + + SkipSegmentTableCompanion copyWith( + {Value? id, + Value? start, + Value? end, + Value? trackId, + Value? createdAt}) { + return SkipSegmentTableCompanion( + id: id ?? this.id, + start: start ?? this.start, + end: end ?? this.end, + trackId: trackId ?? this.trackId, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (start.present) { + map['start'] = Variable(start.value); + } + if (end.present) { + map['end'] = Variable(end.value); + } + if (trackId.present) { + map['track_id'] = Variable(trackId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SkipSegmentTableCompanion(') + ..write('id: $id, ') + ..write('start: $start, ') + ..write('end: $end, ') + ..write('trackId: $trackId, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } +} + class $SourceMatchTableTable extends SourceMatchTable with TableInfo<$SourceMatchTableTable, SourceMatchTableData> { @override @@ -1502,581 +2316,288 @@ class SourceMatchTableCompanion extends UpdateCompanion { } } -class $SkipSegmentTableTable extends SkipSegmentTable - with TableInfo<$SkipSegmentTableTable, SkipSegmentTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $SkipSegmentTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _startMeta = const VerificationMeta('start'); - @override - late final GeneratedColumn start = GeneratedColumn( - 'start', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: true); - static const VerificationMeta _endMeta = const VerificationMeta('end'); - @override - late final GeneratedColumn end = GeneratedColumn( - 'end', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: true); - static const VerificationMeta _trackIdMeta = - const VerificationMeta('trackId'); - @override - late final GeneratedColumn trackId = GeneratedColumn( - 'track_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); - @override - late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - @override - List get $columns => [id, start, end, trackId, createdAt]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'skip_segment_table'; - @override - VerificationContext validateIntegrity( - Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('start')) { - context.handle( - _startMeta, start.isAcceptableOrUnknown(data['start']!, _startMeta)); - } else if (isInserting) { - context.missing(_startMeta); - } - if (data.containsKey('end')) { - context.handle( - _endMeta, end.isAcceptableOrUnknown(data['end']!, _endMeta)); - } else if (isInserting) { - context.missing(_endMeta); - } - if (data.containsKey('track_id')) { - context.handle(_trackIdMeta, - trackId.isAcceptableOrUnknown(data['track_id']!, _trackIdMeta)); - } else if (isInserting) { - context.missing(_trackIdMeta); - } - if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - SkipSegmentTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return SkipSegmentTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - start: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}start'])!, - end: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}end'])!, - trackId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}track_id'])!, - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - ); - } - - @override - $SkipSegmentTableTable createAlias(String alias) { - return $SkipSegmentTableTable(attachedDatabase, alias); - } -} - -class SkipSegmentTableData extends DataClass - implements Insertable { - final int id; - final int start; - final int end; - final String trackId; - final DateTime createdAt; - const SkipSegmentTableData( - {required this.id, - required this.start, - required this.end, - required this.trackId, - required this.createdAt}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['start'] = Variable(start); - map['end'] = Variable(end); - map['track_id'] = Variable(trackId); - map['created_at'] = Variable(createdAt); - return map; - } - - SkipSegmentTableCompanion toCompanion(bool nullToAbsent) { - return SkipSegmentTableCompanion( - id: Value(id), - start: Value(start), - end: Value(end), - trackId: Value(trackId), - createdAt: Value(createdAt), - ); - } - - factory SkipSegmentTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return SkipSegmentTableData( - id: serializer.fromJson(json['id']), - start: serializer.fromJson(json['start']), - end: serializer.fromJson(json['end']), - trackId: serializer.fromJson(json['trackId']), - createdAt: serializer.fromJson(json['createdAt']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'start': serializer.toJson(start), - 'end': serializer.toJson(end), - 'trackId': serializer.toJson(trackId), - 'createdAt': serializer.toJson(createdAt), - }; - } - - SkipSegmentTableData copyWith( - {int? id, - int? start, - int? end, - String? trackId, - DateTime? createdAt}) => - SkipSegmentTableData( - id: id ?? this.id, - start: start ?? this.start, - end: end ?? this.end, - trackId: trackId ?? this.trackId, - createdAt: createdAt ?? this.createdAt, - ); - @override - String toString() { - return (StringBuffer('SkipSegmentTableData(') - ..write('id: $id, ') - ..write('start: $start, ') - ..write('end: $end, ') - ..write('trackId: $trackId, ') - ..write('createdAt: $createdAt') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(id, start, end, trackId, createdAt); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is SkipSegmentTableData && - other.id == this.id && - other.start == this.start && - other.end == this.end && - other.trackId == this.trackId && - other.createdAt == this.createdAt); -} - -class SkipSegmentTableCompanion extends UpdateCompanion { - final Value id; - final Value start; - final Value end; - final Value trackId; - final Value createdAt; - const SkipSegmentTableCompanion({ - this.id = const Value.absent(), - this.start = const Value.absent(), - this.end = const Value.absent(), - this.trackId = const Value.absent(), - this.createdAt = const Value.absent(), - }); - SkipSegmentTableCompanion.insert({ - this.id = const Value.absent(), - required int start, - required int end, - required String trackId, - this.createdAt = const Value.absent(), - }) : start = Value(start), - end = Value(end), - trackId = Value(trackId); - static Insertable custom({ - Expression? id, - Expression? start, - Expression? end, - Expression? trackId, - Expression? createdAt, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (start != null) 'start': start, - if (end != null) 'end': end, - if (trackId != null) 'track_id': trackId, - if (createdAt != null) 'created_at': createdAt, - }); - } - - SkipSegmentTableCompanion copyWith( - {Value? id, - Value? start, - Value? end, - Value? trackId, - Value? createdAt}) { - return SkipSegmentTableCompanion( - id: id ?? this.id, - start: start ?? this.start, - end: end ?? this.end, - trackId: trackId ?? this.trackId, - createdAt: createdAt ?? this.createdAt, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (start.present) { - map['start'] = Variable(start.value); - } - if (end.present) { - map['end'] = Variable(end.value); - } - if (trackId.present) { - map['track_id'] = Variable(trackId.value); - } - if (createdAt.present) { - map['created_at'] = Variable(createdAt.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('SkipSegmentTableCompanion(') - ..write('id: $id, ') - ..write('start: $start, ') - ..write('end: $end, ') - ..write('trackId: $trackId, ') - ..write('createdAt: $createdAt') - ..write(')')) - .toString(); - } -} - -class $BlacklistTableTable extends BlacklistTable - with TableInfo<$BlacklistTableTable, BlacklistTableData> { - @override - final GeneratedDatabase attachedDatabase; - final String? _alias; - $BlacklistTableTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - hasAutoIncrement: true, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); - static const VerificationMeta _nameMeta = const VerificationMeta('name'); - @override - late final GeneratedColumn name = GeneratedColumn( - 'name', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _elementTypeMeta = - const VerificationMeta('elementType'); - @override - late final GeneratedColumnWithTypeConverter - elementType = GeneratedColumn('element_type', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter( - $BlacklistTableTable.$converterelementType); - static const VerificationMeta _elementIdMeta = - const VerificationMeta('elementId'); - @override - late final GeneratedColumn elementId = GeneratedColumn( - 'element_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - @override - List get $columns => [id, name, elementType, elementId]; - @override - String get aliasedName => _alias ?? actualTableName; - @override - String get actualTableName => $name; - static const String $name = 'blacklist_table'; - @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { - final context = VerificationContext(); - final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } - if (data.containsKey('name')) { - context.handle( - _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); - } else if (isInserting) { - context.missing(_nameMeta); - } - context.handle(_elementTypeMeta, const VerificationResult.success()); - if (data.containsKey('element_id')) { - context.handle(_elementIdMeta, - elementId.isAcceptableOrUnknown(data['element_id']!, _elementIdMeta)); - } else if (isInserting) { - context.missing(_elementIdMeta); - } - return context; - } - - @override - Set get $primaryKey => {id}; - @override - BlacklistTableData map(Map data, {String? tablePrefix}) { - final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return BlacklistTableData( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - name: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}name'])!, - elementType: $BlacklistTableTable.$converterelementType.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}element_type'])!), - elementId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}element_id'])!, - ); - } - - @override - $BlacklistTableTable createAlias(String alias) { - return $BlacklistTableTable(attachedDatabase, alias); - } - - static JsonTypeConverter2 - $converterelementType = - const EnumNameConverter(BlacklistedType.values); -} - -class BlacklistTableData extends DataClass - implements Insertable { - final int id; - final String name; - final BlacklistedType elementType; - final String elementId; - const BlacklistTableData( - {required this.id, - required this.name, - required this.elementType, - required this.elementId}); - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - map['id'] = Variable(id); - map['name'] = Variable(name); - { - map['element_type'] = Variable( - $BlacklistTableTable.$converterelementType.toSql(elementType)); - } - map['element_id'] = Variable(elementId); - return map; - } - - BlacklistTableCompanion toCompanion(bool nullToAbsent) { - return BlacklistTableCompanion( - id: Value(id), - name: Value(name), - elementType: Value(elementType), - elementId: Value(elementId), - ); - } - - factory BlacklistTableData.fromJson(Map json, - {ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return BlacklistTableData( - id: serializer.fromJson(json['id']), - name: serializer.fromJson(json['name']), - elementType: $BlacklistTableTable.$converterelementType - .fromJson(serializer.fromJson(json['elementType'])), - elementId: serializer.fromJson(json['elementId']), - ); - } - @override - Map toJson({ValueSerializer? serializer}) { - serializer ??= driftRuntimeOptions.defaultSerializer; - return { - 'id': serializer.toJson(id), - 'name': serializer.toJson(name), - 'elementType': serializer.toJson( - $BlacklistTableTable.$converterelementType.toJson(elementType)), - 'elementId': serializer.toJson(elementId), - }; - } - - BlacklistTableData copyWith( - {int? id, - String? name, - BlacklistedType? elementType, - String? elementId}) => - BlacklistTableData( - id: id ?? this.id, - name: name ?? this.name, - elementType: elementType ?? this.elementType, - elementId: elementId ?? this.elementId, - ); - @override - String toString() { - return (StringBuffer('BlacklistTableData(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('elementType: $elementType, ') - ..write('elementId: $elementId') - ..write(')')) - .toString(); - } - - @override - int get hashCode => Object.hash(id, name, elementType, elementId); - @override - bool operator ==(Object other) => - identical(this, other) || - (other is BlacklistTableData && - other.id == this.id && - other.name == this.name && - other.elementType == this.elementType && - other.elementId == this.elementId); -} - -class BlacklistTableCompanion extends UpdateCompanion { - final Value id; - final Value name; - final Value elementType; - final Value elementId; - const BlacklistTableCompanion({ - this.id = const Value.absent(), - this.name = const Value.absent(), - this.elementType = const Value.absent(), - this.elementId = const Value.absent(), - }); - BlacklistTableCompanion.insert({ - this.id = const Value.absent(), - required String name, - required BlacklistedType elementType, - required String elementId, - }) : name = Value(name), - elementType = Value(elementType), - elementId = Value(elementId); - static Insertable custom({ - Expression? id, - Expression? name, - Expression? elementType, - Expression? elementId, - }) { - return RawValuesInsertable({ - if (id != null) 'id': id, - if (name != null) 'name': name, - if (elementType != null) 'element_type': elementType, - if (elementId != null) 'element_id': elementId, - }); - } - - BlacklistTableCompanion copyWith( - {Value? id, - Value? name, - Value? elementType, - Value? elementId}) { - return BlacklistTableCompanion( - id: id ?? this.id, - name: name ?? this.name, - elementType: elementType ?? this.elementType, - elementId: elementId ?? this.elementId, - ); - } - - @override - Map toColumns(bool nullToAbsent) { - final map = {}; - if (id.present) { - map['id'] = Variable(id.value); - } - if (name.present) { - map['name'] = Variable(name.value); - } - if (elementType.present) { - map['element_type'] = Variable( - $BlacklistTableTable.$converterelementType.toSql(elementType.value)); - } - if (elementId.present) { - map['element_id'] = Variable(elementId.value); - } - return map; - } - - @override - String toString() { - return (StringBuffer('BlacklistTableCompanion(') - ..write('id: $id, ') - ..write('name: $name, ') - ..write('elementType: $elementType, ') - ..write('elementId: $elementId') - ..write(')')) - .toString(); - } -} - abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); _$AppDatabaseManager get managers => _$AppDatabaseManager(this); + late final $AuthenticationTableTable authenticationTable = + $AuthenticationTableTable(this); + late final $BlacklistTableTable blacklistTable = $BlacklistTableTable(this); late final $PreferencesTableTable preferencesTable = $PreferencesTableTable(this); - late final $SourceMatchTableTable sourceMatchTable = - $SourceMatchTableTable(this); late final $SkipSegmentTableTable skipSegmentTable = $SkipSegmentTableTable(this); - late final $BlacklistTableTable blacklistTable = $BlacklistTableTable(this); - late final Index uniqTrackMatch = Index('uniq_track_match', - 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); + late final $SourceMatchTableTable sourceMatchTable = + $SourceMatchTableTable(this); late final Index uniqueBlacklist = Index('unique_blacklist', 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); + late final Index uniqTrackMatch = Index('uniq_track_match', + 'CREATE UNIQUE INDEX uniq_track_match ON source_match_table (track_id, source_id, source_type)'); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override List get allSchemaEntities => [ - preferencesTable, - sourceMatchTable, - skipSegmentTable, + authenticationTable, blacklistTable, - uniqTrackMatch, - uniqueBlacklist + preferencesTable, + skipSegmentTable, + sourceMatchTable, + uniqueBlacklist, + uniqTrackMatch ]; } +typedef $$AuthenticationTableTableInsertCompanionBuilder + = AuthenticationTableCompanion Function({ + Value id, + required DecryptedText cookie, + required DecryptedText accessToken, + required DateTime expiration, +}); +typedef $$AuthenticationTableTableUpdateCompanionBuilder + = AuthenticationTableCompanion Function({ + Value id, + Value cookie, + Value accessToken, + Value expiration, +}); + +class $$AuthenticationTableTableTableManager extends RootTableManager< + _$AppDatabase, + $AuthenticationTableTable, + AuthenticationTableData, + $$AuthenticationTableTableFilterComposer, + $$AuthenticationTableTableOrderingComposer, + $$AuthenticationTableTableProcessedTableManager, + $$AuthenticationTableTableInsertCompanionBuilder, + $$AuthenticationTableTableUpdateCompanionBuilder> { + $$AuthenticationTableTableTableManager( + _$AppDatabase db, $AuthenticationTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $$AuthenticationTableTableFilterComposer( + ComposerState(db, table)), + orderingComposer: $$AuthenticationTableTableOrderingComposer( + ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$AuthenticationTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value cookie = const Value.absent(), + Value accessToken = const Value.absent(), + Value expiration = const Value.absent(), + }) => + AuthenticationTableCompanion( + id: id, + cookie: cookie, + accessToken: accessToken, + expiration: expiration, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required DecryptedText cookie, + required DecryptedText accessToken, + required DateTime expiration, + }) => + AuthenticationTableCompanion.insert( + id: id, + cookie: cookie, + accessToken: accessToken, + expiration: expiration, + ), + )); +} + +class $$AuthenticationTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $AuthenticationTableTable, + AuthenticationTableData, + $$AuthenticationTableTableFilterComposer, + $$AuthenticationTableTableOrderingComposer, + $$AuthenticationTableTableProcessedTableManager, + $$AuthenticationTableTableInsertCompanionBuilder, + $$AuthenticationTableTableUpdateCompanionBuilder> { + $$AuthenticationTableTableProcessedTableManager(super.$state); +} + +class $$AuthenticationTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $AuthenticationTableTable> { + $$AuthenticationTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get cookie => $state.composableBuilder( + column: $state.table.cookie, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get accessToken => $state.composableBuilder( + column: $state.table.accessToken, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get expiration => $state.composableBuilder( + column: $state.table.expiration, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$AuthenticationTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $AuthenticationTableTable> { + $$AuthenticationTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get cookie => $state.composableBuilder( + column: $state.table.cookie, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get accessToken => $state.composableBuilder( + column: $state.table.accessToken, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get expiration => $state.composableBuilder( + column: $state.table.expiration, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$BlacklistTableTableInsertCompanionBuilder = BlacklistTableCompanion + Function({ + Value id, + required String name, + required BlacklistedType elementType, + required String elementId, +}); +typedef $$BlacklistTableTableUpdateCompanionBuilder = BlacklistTableCompanion + Function({ + Value id, + Value name, + Value elementType, + Value elementId, +}); + +class $$BlacklistTableTableTableManager extends RootTableManager< + _$AppDatabase, + $BlacklistTableTable, + BlacklistTableData, + $$BlacklistTableTableFilterComposer, + $$BlacklistTableTableOrderingComposer, + $$BlacklistTableTableProcessedTableManager, + $$BlacklistTableTableInsertCompanionBuilder, + $$BlacklistTableTableUpdateCompanionBuilder> { + $$BlacklistTableTableTableManager( + _$AppDatabase db, $BlacklistTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$BlacklistTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$BlacklistTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$BlacklistTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value elementType = const Value.absent(), + Value elementId = const Value.absent(), + }) => + BlacklistTableCompanion( + id: id, + name: name, + elementType: elementType, + elementId: elementId, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required String name, + required BlacklistedType elementType, + required String elementId, + }) => + BlacklistTableCompanion.insert( + id: id, + name: name, + elementType: elementType, + elementId: elementId, + ), + )); +} + +class $$BlacklistTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $BlacklistTableTable, + BlacklistTableData, + $$BlacklistTableTableFilterComposer, + $$BlacklistTableTableOrderingComposer, + $$BlacklistTableTableProcessedTableManager, + $$BlacklistTableTableInsertCompanionBuilder, + $$BlacklistTableTableUpdateCompanionBuilder> { + $$BlacklistTableTableProcessedTableManager(super.$state); +} + +class $$BlacklistTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $BlacklistTableTable> { + $$BlacklistTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get name => $state.composableBuilder( + column: $state.table.name, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get elementType => $state.composableBuilder( + column: $state.table.elementType, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get elementId => $state.composableBuilder( + column: $state.table.elementId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$BlacklistTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $BlacklistTableTable> { + $$BlacklistTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get name => $state.composableBuilder( + column: $state.table.name, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get elementType => $state.composableBuilder( + column: $state.table.elementType, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get elementId => $state.composableBuilder( + column: $state.table.elementId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + typedef $$PreferencesTableTableInsertCompanionBuilder = PreferencesTableCompanion Function({ Value id, @@ -2560,6 +3081,145 @@ class $$PreferencesTableTableOrderingComposer ColumnOrderings(column, joinBuilders: joinBuilders)); } +typedef $$SkipSegmentTableTableInsertCompanionBuilder + = SkipSegmentTableCompanion Function({ + Value id, + required int start, + required int end, + required String trackId, + Value createdAt, +}); +typedef $$SkipSegmentTableTableUpdateCompanionBuilder + = SkipSegmentTableCompanion Function({ + Value id, + Value start, + Value end, + Value trackId, + Value createdAt, +}); + +class $$SkipSegmentTableTableTableManager extends RootTableManager< + _$AppDatabase, + $SkipSegmentTableTable, + SkipSegmentTableData, + $$SkipSegmentTableTableFilterComposer, + $$SkipSegmentTableTableOrderingComposer, + $$SkipSegmentTableTableProcessedTableManager, + $$SkipSegmentTableTableInsertCompanionBuilder, + $$SkipSegmentTableTableUpdateCompanionBuilder> { + $$SkipSegmentTableTableTableManager( + _$AppDatabase db, $SkipSegmentTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$SkipSegmentTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$SkipSegmentTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$SkipSegmentTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value start = const Value.absent(), + Value end = const Value.absent(), + Value trackId = const Value.absent(), + Value createdAt = const Value.absent(), + }) => + SkipSegmentTableCompanion( + id: id, + start: start, + end: end, + trackId: trackId, + createdAt: createdAt, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required int start, + required int end, + required String trackId, + Value createdAt = const Value.absent(), + }) => + SkipSegmentTableCompanion.insert( + id: id, + start: start, + end: end, + trackId: trackId, + createdAt: createdAt, + ), + )); +} + +class $$SkipSegmentTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $SkipSegmentTableTable, + SkipSegmentTableData, + $$SkipSegmentTableTableFilterComposer, + $$SkipSegmentTableTableOrderingComposer, + $$SkipSegmentTableTableProcessedTableManager, + $$SkipSegmentTableTableInsertCompanionBuilder, + $$SkipSegmentTableTableUpdateCompanionBuilder> { + $$SkipSegmentTableTableProcessedTableManager(super.$state); +} + +class $$SkipSegmentTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $SkipSegmentTableTable> { + $$SkipSegmentTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get start => $state.composableBuilder( + column: $state.table.start, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get end => $state.composableBuilder( + column: $state.table.end, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$SkipSegmentTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $SkipSegmentTableTable> { + $$SkipSegmentTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get start => $state.composableBuilder( + column: $state.table.start, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get end => $state.composableBuilder( + column: $state.table.end, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + typedef $$SourceMatchTableTableInsertCompanionBuilder = SourceMatchTableCompanion Function({ Value id, @@ -2701,278 +3361,17 @@ class $$SourceMatchTableTableOrderingComposer ColumnOrderings(column, joinBuilders: joinBuilders)); } -typedef $$SkipSegmentTableTableInsertCompanionBuilder - = SkipSegmentTableCompanion Function({ - Value id, - required int start, - required int end, - required String trackId, - Value createdAt, -}); -typedef $$SkipSegmentTableTableUpdateCompanionBuilder - = SkipSegmentTableCompanion Function({ - Value id, - Value start, - Value end, - Value trackId, - Value createdAt, -}); - -class $$SkipSegmentTableTableTableManager extends RootTableManager< - _$AppDatabase, - $SkipSegmentTableTable, - SkipSegmentTableData, - $$SkipSegmentTableTableFilterComposer, - $$SkipSegmentTableTableOrderingComposer, - $$SkipSegmentTableTableProcessedTableManager, - $$SkipSegmentTableTableInsertCompanionBuilder, - $$SkipSegmentTableTableUpdateCompanionBuilder> { - $$SkipSegmentTableTableTableManager( - _$AppDatabase db, $SkipSegmentTableTable table) - : super(TableManagerState( - db: db, - table: table, - filteringComposer: - $$SkipSegmentTableTableFilterComposer(ComposerState(db, table)), - orderingComposer: - $$SkipSegmentTableTableOrderingComposer(ComposerState(db, table)), - getChildManagerBuilder: (p) => - $$SkipSegmentTableTableProcessedTableManager(p), - getUpdateCompanionBuilder: ({ - Value id = const Value.absent(), - Value start = const Value.absent(), - Value end = const Value.absent(), - Value trackId = const Value.absent(), - Value createdAt = const Value.absent(), - }) => - SkipSegmentTableCompanion( - id: id, - start: start, - end: end, - trackId: trackId, - createdAt: createdAt, - ), - getInsertCompanionBuilder: ({ - Value id = const Value.absent(), - required int start, - required int end, - required String trackId, - Value createdAt = const Value.absent(), - }) => - SkipSegmentTableCompanion.insert( - id: id, - start: start, - end: end, - trackId: trackId, - createdAt: createdAt, - ), - )); -} - -class $$SkipSegmentTableTableProcessedTableManager - extends ProcessedTableManager< - _$AppDatabase, - $SkipSegmentTableTable, - SkipSegmentTableData, - $$SkipSegmentTableTableFilterComposer, - $$SkipSegmentTableTableOrderingComposer, - $$SkipSegmentTableTableProcessedTableManager, - $$SkipSegmentTableTableInsertCompanionBuilder, - $$SkipSegmentTableTableUpdateCompanionBuilder> { - $$SkipSegmentTableTableProcessedTableManager(super.$state); -} - -class $$SkipSegmentTableTableFilterComposer - extends FilterComposer<_$AppDatabase, $SkipSegmentTableTable> { - $$SkipSegmentTableTableFilterComposer(super.$state); - ColumnFilters get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get start => $state.composableBuilder( - column: $state.table.start, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get end => $state.composableBuilder( - column: $state.table.end, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get trackId => $state.composableBuilder( - column: $state.table.trackId, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get createdAt => $state.composableBuilder( - column: $state.table.createdAt, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); -} - -class $$SkipSegmentTableTableOrderingComposer - extends OrderingComposer<_$AppDatabase, $SkipSegmentTableTable> { - $$SkipSegmentTableTableOrderingComposer(super.$state); - ColumnOrderings get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get start => $state.composableBuilder( - column: $state.table.start, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get end => $state.composableBuilder( - column: $state.table.end, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get trackId => $state.composableBuilder( - column: $state.table.trackId, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get createdAt => $state.composableBuilder( - column: $state.table.createdAt, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); -} - -typedef $$BlacklistTableTableInsertCompanionBuilder = BlacklistTableCompanion - Function({ - Value id, - required String name, - required BlacklistedType elementType, - required String elementId, -}); -typedef $$BlacklistTableTableUpdateCompanionBuilder = BlacklistTableCompanion - Function({ - Value id, - Value name, - Value elementType, - Value elementId, -}); - -class $$BlacklistTableTableTableManager extends RootTableManager< - _$AppDatabase, - $BlacklistTableTable, - BlacklistTableData, - $$BlacklistTableTableFilterComposer, - $$BlacklistTableTableOrderingComposer, - $$BlacklistTableTableProcessedTableManager, - $$BlacklistTableTableInsertCompanionBuilder, - $$BlacklistTableTableUpdateCompanionBuilder> { - $$BlacklistTableTableTableManager( - _$AppDatabase db, $BlacklistTableTable table) - : super(TableManagerState( - db: db, - table: table, - filteringComposer: - $$BlacklistTableTableFilterComposer(ComposerState(db, table)), - orderingComposer: - $$BlacklistTableTableOrderingComposer(ComposerState(db, table)), - getChildManagerBuilder: (p) => - $$BlacklistTableTableProcessedTableManager(p), - getUpdateCompanionBuilder: ({ - Value id = const Value.absent(), - Value name = const Value.absent(), - Value elementType = const Value.absent(), - Value elementId = const Value.absent(), - }) => - BlacklistTableCompanion( - id: id, - name: name, - elementType: elementType, - elementId: elementId, - ), - getInsertCompanionBuilder: ({ - Value id = const Value.absent(), - required String name, - required BlacklistedType elementType, - required String elementId, - }) => - BlacklistTableCompanion.insert( - id: id, - name: name, - elementType: elementType, - elementId: elementId, - ), - )); -} - -class $$BlacklistTableTableProcessedTableManager extends ProcessedTableManager< - _$AppDatabase, - $BlacklistTableTable, - BlacklistTableData, - $$BlacklistTableTableFilterComposer, - $$BlacklistTableTableOrderingComposer, - $$BlacklistTableTableProcessedTableManager, - $$BlacklistTableTableInsertCompanionBuilder, - $$BlacklistTableTableUpdateCompanionBuilder> { - $$BlacklistTableTableProcessedTableManager(super.$state); -} - -class $$BlacklistTableTableFilterComposer - extends FilterComposer<_$AppDatabase, $BlacklistTableTable> { - $$BlacklistTableTableFilterComposer(super.$state); - ColumnFilters get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnFilters get name => $state.composableBuilder( - column: $state.table.name, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - - ColumnWithTypeConverterFilters - get elementType => $state.composableBuilder( - column: $state.table.elementType, - builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( - column, - joinBuilders: joinBuilders)); - - ColumnFilters get elementId => $state.composableBuilder( - column: $state.table.elementId, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); -} - -class $$BlacklistTableTableOrderingComposer - extends OrderingComposer<_$AppDatabase, $BlacklistTableTable> { - $$BlacklistTableTableOrderingComposer(super.$state); - ColumnOrderings get id => $state.composableBuilder( - column: $state.table.id, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get name => $state.composableBuilder( - column: $state.table.name, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get elementType => $state.composableBuilder( - column: $state.table.elementType, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - - ColumnOrderings get elementId => $state.composableBuilder( - column: $state.table.elementId, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); -} - class _$AppDatabaseManager { final _$AppDatabase _db; _$AppDatabaseManager(this._db); - $$PreferencesTableTableTableManager get preferencesTable => - $$PreferencesTableTableTableManager(_db, _db.preferencesTable); - $$SourceMatchTableTableTableManager get sourceMatchTable => - $$SourceMatchTableTableTableManager(_db, _db.sourceMatchTable); - $$SkipSegmentTableTableTableManager get skipSegmentTable => - $$SkipSegmentTableTableTableManager(_db, _db.skipSegmentTable); + $$AuthenticationTableTableTableManager get authenticationTable => + $$AuthenticationTableTableTableManager(_db, _db.authenticationTable); $$BlacklistTableTableTableManager get blacklistTable => $$BlacklistTableTableTableManager(_db, _db.blacklistTable); + $$PreferencesTableTableTableManager get preferencesTable => + $$PreferencesTableTableTableManager(_db, _db.preferencesTable); + $$SkipSegmentTableTableTableManager get skipSegmentTable => + $$SkipSegmentTableTableTableManager(_db, _db.skipSegmentTable); + $$SourceMatchTableTableTableManager get sourceMatchTable => + $$SourceMatchTableTableTableManager(_db, _db.sourceMatchTable); } diff --git a/lib/models/database/tables/authentication.dart b/lib/models/database/tables/authentication.dart new file mode 100644 index 00000000..96041952 --- /dev/null +++ b/lib/models/database/tables/authentication.dart @@ -0,0 +1,8 @@ +part of '../database.dart'; + +class AuthenticationTable extends Table { + IntColumn get id => integer().autoIncrement()(); + TextColumn get cookie => text().map(EncryptedTextConverter())(); + TextColumn get accessToken => text().map(EncryptedTextConverter())(); + DateTimeColumn get expiration => dateTime()(); +} diff --git a/lib/models/database/typeconverters/encrypted_text.dart b/lib/models/database/typeconverters/encrypted_text.dart index 27921788..6afa8210 100644 --- a/lib/models/database/typeconverters/encrypted_text.dart +++ b/lib/models/database/typeconverters/encrypted_text.dart @@ -22,6 +22,11 @@ class DecryptedText { } String encrypt() { + _encrypter ??= Encrypter( + Salsa20( + Key.fromUtf8(EncryptedKvStoreService.encryptionKeySync), + ), + ); return _encrypter!.encrypt(value, iv: KVStoreService.ivKey).base64; } } diff --git a/lib/models/database/typeconverters/string_list.dart b/lib/models/database/typeconverters/string_list.dart index 5c30a997..466ae4c4 100644 --- a/lib/models/database/typeconverters/string_list.dart +++ b/lib/models/database/typeconverters/string_list.dart @@ -5,7 +5,7 @@ class StringListConverter extends TypeConverter, String> { @override List fromSql(String fromDb) { - return fromDb.split(","); + return fromDb.split(",").where((e) => e.isNotEmpty).toList(); } @override diff --git a/lib/modules/desktop_login/login_form.dart b/lib/modules/desktop_login/login_form.dart index 6091829c..e5d31215 100644 --- a/lib/modules/desktop_login/login_form.dart +++ b/lib/modules/desktop_login/login_form.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; class TokenLoginForm extends HookConsumerWidget { final void Function()? onDone; @@ -52,10 +52,7 @@ class TokenLoginForm extends HookConsumerWidget { final cookieHeader = "sp_dc=${directCodeController.text.trim()}"; - authenticationNotifier.setCredentials( - await AuthenticationCredentials.fromCookie( - cookieHeader), - ); + await authenticationNotifier.login(cookieHeader); if (context.mounted) { onDone?.call(); } diff --git a/lib/modules/home/sections/friends.dart b/lib/modules/home/sections/friends.dart index 85325f5a..d6bed6a8 100644 --- a/lib/modules/home/sections/friends.dart +++ b/lib/modules/home/sections/friends.dart @@ -8,7 +8,7 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/modules/home/sections/friends/friend_item.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/spotify_friends.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HomePageFriendsSection extends HookConsumerWidget { @@ -59,7 +59,7 @@ class HomePageFriendsSection extends HookConsumerWidget { if (friendsQuery.isLoading || friendsQuery.asData?.value.friends.isEmpty == true || - auth == null) { + auth.asData?.value == null) { return const SliverToBoxAdapter( child: SizedBox.shrink(), ); diff --git a/lib/modules/home/sections/new_releases.dart b/lib/modules/home/sections/new_releases.dart index 08b28138..e2b32741 100644 --- a/lib/modules/home/sections/new_releases.dart +++ b/lib/modules/home/sections/new_releases.dart @@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/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/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class HomeNewReleasesSection extends HookConsumerWidget { @@ -18,7 +18,7 @@ class HomeNewReleasesSection extends HookConsumerWidget { final albums = ref.watch(userArtistAlbumReleasesProvider); - if (auth == null || + if (auth.asData?.value == null || newReleases.isLoading || newReleases.asData?.value.items.isEmpty == true) { return const SizedBox.shrink(); diff --git a/lib/modules/library/local_folder/local_folder_item.dart b/lib/modules/library/local_folder/local_folder_item.dart index 9408d008..a5831fc2 100644 --- a/lib/modules/library/local_folder/local_folder_item.dart +++ b/lib/modules/library/local_folder/local_folder_item.dart @@ -46,7 +46,7 @@ class LocalFolderItem extends HookConsumerWidget { ...pathSegments.skip(pathSegments.length - 3).toList() ..removeLast(), ] - : pathSegments.take(pathSegments.length - 1).toList(); + : pathSegments.take(max(pathSegments.length - 1, 0)).toList(); final trackSnapshot = ref.watch( localTracksProvider.select( diff --git a/lib/modules/library/user_albums.dart b/lib/modules/library/user_albums.dart index 71e5b65a..c2c91293 100644 --- a/lib/modules/library/user_albums.dart +++ b/lib/modules/library/user_albums.dart @@ -14,7 +14,7 @@ import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class UserAlbums extends HookConsumerWidget { @@ -46,7 +46,7 @@ class UserAlbums extends HookConsumerWidget { []; }, [albumsQuery.asData?.value, searchText.value]); - if (auth == null) { + if (auth.asData?.value == null) { return const AnonymousFallback(); } diff --git a/lib/modules/library/user_artists.dart b/lib/modules/library/user_artists.dart index dbdd8682..dd097080 100644 --- a/lib/modules/library/user_artists.dart +++ b/lib/modules/library/user_artists.dart @@ -14,7 +14,7 @@ import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class UserArtists extends HookConsumerWidget { @@ -48,7 +48,7 @@ class UserArtists extends HookConsumerWidget { final controller = useScrollController(); - if (auth == null) { + if (auth.asData?.value == null) { return const AnonymousFallback(); } diff --git a/lib/modules/library/user_playlists.dart b/lib/modules/library/user_playlists.dart index e0c501bb..577f9655 100644 --- a/lib/modules/library/user_playlists.dart +++ b/lib/modules/library/user_playlists.dart @@ -17,7 +17,7 @@ import 'package:spotube/components/waypoint.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -75,7 +75,7 @@ class UserPlaylists extends HookConsumerWidget { final controller = useScrollController(); - if (auth == null) { + if (auth.asData?.value == null) { return const AnonymousFallback(); } diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index 6a8a3e52..66344792 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -24,7 +24,7 @@ import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/volume_provider.dart'; diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart index 41de7388..8fd434ad 100644 --- a/lib/modules/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -13,7 +13,7 @@ import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/sleep_timer_provider.dart'; diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart index 14784176..a77ab6fe 100644 --- a/lib/modules/root/bottom_player.dart +++ b/lib/modules/root/bottom_player.dart @@ -18,7 +18,7 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/modules/root/sidebar.dart b/lib/modules/root/sidebar.dart index 592a3d90..ef735798 100644 --- a/lib/modules/root/sidebar.dart +++ b/lib/modules/root/sidebar.dart @@ -20,7 +20,7 @@ import 'package:spotube/hooks/controllers/use_sidebarx_controller.dart'; import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -269,7 +269,7 @@ class SidebarFooter extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - if (auth != null && data == null) + if (auth.asData?.value != null && data == null) const CircularProgressIndicator() else if (data != null) Flexible( diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index a30535dd..7d7fa8ef 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/primitive_utils.dart'; diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart index d78143e4..ec62543c 100644 --- a/lib/pages/desktop_login/login_tutorial.dart +++ b/lib/pages/desktop_login/login_tutorial.dart @@ -9,7 +9,7 @@ import 'package:spotube/components/links/hyper_link.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/service_utils.dart'; class LoginTutorial extends ConsumerWidget { @@ -18,8 +18,7 @@ class LoginTutorial extends ConsumerWidget { @override Widget build(BuildContext context, ref) { - ref.watch(authenticationProvider); - final authenticationNotifier = ref.watch(authenticationProvider.notifier); + final auth = ref.watch(authenticationProvider); final key = GlobalKey>(); final theme = Theme.of(context); @@ -53,7 +52,7 @@ class LoginTutorial extends ConsumerWidget { ), showBackButton: true, overrideDone: FilledButton( - onPressed: authenticationNotifier.isLoggedIn + onPressed: auth.asData?.value != null ? () { ServiceUtils.pushNamed(context, HomePage.name); } @@ -91,7 +90,7 @@ class LoginTutorial extends ConsumerWidget { bodyWidget: Text(context.l10n.step_3_steps, textAlign: TextAlign.left), ), - if (authenticationNotifier.isLoggedIn) + if (auth.asData?.value != null) PageViewModel( decoration: pageDecoration.copyWith( bodyAlignment: Alignment.center, diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index f75c715c..c484046b 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -17,7 +17,7 @@ import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; import 'package:spotube/pages/lyrics/synced_lyrics.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -84,7 +84,7 @@ class LyricsPage extends HookConsumerWidget { final auth = ref.watch(authenticationProvider); - if (auth == null) { + if (auth.asData?.value == null) { return Scaffold( appBar: !kIsMacOS && !isModal ? const PageWindowTitleBar() : null, body: const AnonymousFallback(), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 603f90d3..f9659538 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -14,7 +14,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_force_update.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; import 'package:spotube/pages/lyrics/synced_lyrics.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; @@ -48,7 +48,7 @@ class MiniLyricsPage extends HookConsumerWidget { final auth = ref.watch(authenticationProvider); - if (auth == null) { + if (auth.asData?.value == null) { return const Scaffold( appBar: PageWindowTitleBar(), body: AnonymousFallback(), diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 1f2df95a..290c2b2f 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -3,7 +3,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/platform.dart'; class WebViewLogin extends HookConsumerWidget { @@ -53,9 +53,7 @@ class WebViewLogin extends HookConsumerWidget { final cookieHeader = "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; - authenticationNotifier.setCredentials( - await AuthenticationCredentials.fromCookie(cookieHeader), - ); + await authenticationNotifier.login(cookieHeader); if (context.mounted) { // ignore: use_build_context_synchronously GoRouter.of(context).go("/"); diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index 4f53f8e6..e28a5eff 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -20,7 +20,7 @@ import 'package:spotube/pages/search/sections/albums.dart'; import 'package:spotube/pages/search/sections/artists.dart'; import 'package:spotube/pages/search/sections/playlists.dart'; import 'package:spotube/pages/search/sections/tracks.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; @@ -37,8 +37,7 @@ class SearchPage extends HookConsumerWidget { final searchTerm = ref.watch(searchTermStateProvider); final controller = useSearchController(); - ref.watch(authenticationProvider); - final authenticationNotifier = ref.watch(authenticationProvider.notifier); + final auth = ref.watch(authenticationProvider); final mediaQuery = MediaQuery.of(context); final searchTrack = ref.watch(searchProvider(SearchType.track)); @@ -91,7 +90,7 @@ class SearchPage extends HookConsumerWidget { appBar: kIsDesktop && !kIsMacOS ? const PageWindowTitleBar(automaticallyImplyLeading: true) : null, - body: !authenticationNotifier.isLoggedIn + body: auth.asData?.value == null ? const AnonymousFallback() : Column( children: [ diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index a007fbeb..1604f14b 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -9,7 +9,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/pages/profile/profile.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/scrobbler_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -35,7 +35,7 @@ class SettingsAccountSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.account, children: [ - if (auth != null) + if (auth.asData?.value != null) ListTile( leading: const Icon(SpotubeIcons.user), title: const Text("User Profile"), @@ -53,7 +53,7 @@ class SettingsAccountSection extends HookConsumerWidget { ServiceUtils.pushNamed(context, ProfilePage.name); }, ), - if (auth == null) + if (auth.asData?.value == null) LayoutBuilder(builder: (context, constrains) { return ListTile( leading: Icon( diff --git a/lib/provider/authentication_provider.dart b/lib/provider/authentication/authentication.dart similarity index 51% rename from lib/provider/authentication_provider.dart rename to lib/provider/authentication/authentication.dart index 52c7f281..3ea8693b 100644 --- a/lib/provider/authentication_provider.dart +++ b/lib/provider/authentication/authentication.dart @@ -4,22 +4,30 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; +import 'package:drift/drift.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart' hide X509Certificate; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/components/dialogs/prompt_dialog.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/utils/platform.dart'; -class AuthenticationCredentials { - String cookie; - String accessToken; - DateTime expiration; - +extension ExpirationAuthenticationTableData on AuthenticationTableData { bool get isExpired => DateTime.now().isAfter(expiration); + String? getCookie(String key) => cookie.value + .split("; ") + .firstWhereOrNull((c) => c.trim().startsWith("$key=")) + ?.trim() + .split("=") + .last + .replaceAll(";", ""); +} + +class AuthenticationNotifier extends AsyncNotifier { static final Dio dio = () { final dio = Dio(); @@ -32,13 +40,68 @@ class AuthenticationCredentials { return dio; }(); - AuthenticationCredentials({ - required this.cookie, - required this.accessToken, - required this.expiration, - }); + @override + build() async { + final database = ref.watch(databaseProvider); - static Future fromCookie(String cookie) async { + final data = await (database.select(database.authenticationTable) + ..where((s) => s.id.equals(0))) + .getSingleOrNull(); + + Timer? refreshTimer; + + ref.listenSelf((prevData, newData) async { + if (newData.asData?.value == null) return; + + if (newData.asData!.value!.isExpired) { + await refreshCredentials(); + } + + // set the refresh timer + refreshTimer?.cancel(); + refreshTimer = Timer( + newData.asData!.value!.expiration.difference(DateTime.now()), + () => refreshCredentials(), + ); + }); + + final subscription = + database.select(database.authenticationTable).watch().listen( + (event) { + state = AsyncData(event.isEmpty ? null : event.first); + }, + ); + + ref.onDispose(() { + subscription.cancel(); + refreshTimer?.cancel(); + }); + + return data; + } + + Future refreshCredentials() async { + final database = ref.read(databaseProvider); + final refreshedCredentials = + await credentialsFromCookie(state.asData!.value!.cookie.value); + + await database + .update(database.authenticationTable) + .replace(refreshedCredentials); + } + + Future login(String cookie) async { + final database = ref.read(databaseProvider); + final refreshedCredentials = await credentialsFromCookie(cookie); + + await database + .into(database.authenticationTable) + .insert(refreshedCredentials); + } + + Future credentialsFromCookie( + String cookie, + ) async { try { final spDc = cookie .split("; ") @@ -65,9 +128,10 @@ class AuthenticationCredentials { ); } - return AuthenticationCredentials( - cookie: "${res.headers["set-cookie"]?.join(";")}; $spDc", - accessToken: body['accessToken'], + return AuthenticationTableCompanion.insert( + id: const Value(0), + cookie: DecryptedText("${res.headers["set-cookie"]?.join(";")}; $spDc"), + accessToken: DecryptedText(body['accessToken']), expiration: DateTime.fromMillisecondsSinceEpoch( body['accessTokenExpirationTimestampMs'], ), @@ -86,102 +150,20 @@ class AuthenticationCredentials { } } - /// Returns the cookie value - String? getCookie(String key) => cookie - .split("; ") - .firstWhereOrNull((c) => c.trim().startsWith("$key=")) - ?.trim() - .split("=") - .last - .replaceAll(";", ""); - - factory AuthenticationCredentials.fromJson(Map json) { - return AuthenticationCredentials( - cookie: json['cookie'] as String, - accessToken: json['accessToken'] as String, - expiration: DateTime.parse(json['expiration'] as String), - ); - } - - Map toJson() { - return { - 'cookie': cookie, - 'accessToken': accessToken, - 'expiration': expiration.toIso8601String(), - }; - } - - AuthenticationCredentials copyWith({ - String? cookie, - String? accessToken, - DateTime? expiration, - }) { - return AuthenticationCredentials( - cookie: cookie ?? this.cookie, - accessToken: accessToken ?? this.accessToken, - expiration: expiration ?? this.expiration, - ); - } -} - -class AuthenticationNotifier - extends PersistedStateNotifier { - bool get isLoggedIn => state != null; - - AuthenticationNotifier() : super(null, "authentication", encrypted: true); - - Timer? _refreshTimer; - - @override - FutureOr onInit() async { - super.onInit(); - if (isLoggedIn && state!.isExpired) { - await refreshCredentials(); - } - - addListener((state) { - _refreshTimer?.cancel(); - if (isLoggedIn && !state!.isExpired) { - _refreshTimer = Timer( - state.expiration.difference(DateTime.now()), - () => refreshCredentials(), - ); - } - }); - } - - void setCredentials(AuthenticationCredentials credentials) { - state = credentials; - } - Future logout() async { - state = null; + state = const AsyncData(null); + final database = ref.read(databaseProvider); + await (database.delete(database.authenticationTable) + ..where((s) => s.id.equals(0))) + .go(); if (kIsMobile) { WebStorageManager.instance().deleteAllData(); CookieManager.instance().deleteAllCookies(); } } - - Future refreshCredentials() async { - if (!isLoggedIn) { - return; - } - - state = await AuthenticationCredentials.fromCookie(state!.cookie); - } - - @override - FutureOr fromJson(Map json) { - return AuthenticationCredentials.fromJson(json); - } - - @override - Map toJson() { - return state?.toJson() ?? {}; - } } final authenticationProvider = - StateNotifierProvider( - (ref) => AuthenticationNotifier(), + AsyncNotifierProvider( + () => AuthenticationNotifier(), ); diff --git a/lib/provider/custom_spotify_endpoint_provider.dart b/lib/provider/custom_spotify_endpoint_provider.dart index 4634549a..ad0c389a 100644 --- a/lib/provider/custom_spotify_endpoint_provider.dart +++ b/lib/provider/custom_spotify_endpoint_provider.dart @@ -1,10 +1,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/custom_spotify_endpoints/spotify_endpoints.dart'; final customSpotifyEndpointProvider = Provider((ref) { ref.watch(spotifyProvider); final auth = ref.watch(authenticationProvider); - return CustomSpotifyEndpoints(auth?.accessToken ?? ""); + return CustomSpotifyEndpoints(auth.asData?.value?.accessToken.value ?? ""); }); diff --git a/lib/provider/spotify/views/home.dart b/lib/provider/spotify/views/home.dart index 51586953..ad6a076a 100644 --- a/lib/provider/spotify/views/home.dart +++ b/lib/provider/spotify/views/home.dart @@ -1,5 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -8,7 +8,7 @@ final homeViewProvider = FutureProvider((ref) async { userPreferencesProvider.select((s) => s.market), ); final spTCookie = ref.watch( - authenticationProvider.select((s) => s?.getCookie("sp_t")), + authenticationProvider.select((s) => s.asData?.value?.getCookie("sp_t")), ); if (spTCookie == null) return null; diff --git a/lib/provider/spotify/views/home_section.dart b/lib/provider/spotify/views/home_section.dart index 04c4cbd6..5eb9183d 100644 --- a/lib/provider/spotify/views/home_section.dart +++ b/lib/provider/spotify/views/home_section.dart @@ -1,6 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/models/spotify/home_feed.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -11,7 +11,7 @@ final homeSectionViewProvider = userPreferencesProvider.select((s) => s.market), ); final spTCookie = ref.watch( - authenticationProvider.select((s) => s?.getCookie("sp_t")), + authenticationProvider.select((s) => s.asData?.value?.getCookie("sp_t")), ); if (spTCookie == null) return null; diff --git a/lib/provider/spotify_provider.dart b/lib/provider/spotify_provider.dart index f8b6e044..5824cce0 100644 --- a/lib/provider/spotify_provider.dart +++ b/lib/provider/spotify_provider.dart @@ -2,14 +2,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/env.dart'; -import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/primitive_utils.dart'; final spotifyProvider = Provider((ref) { final authState = ref.watch(authenticationProvider); final anonCred = PrimitiveUtils.getRandomElement(Env.spotifySecrets); - if (authState == null) { + if (authState.asData?.value == null) { return SpotifyApi( SpotifyApiCredentials( anonCred["clientId"], @@ -18,5 +18,5 @@ final spotifyProvider = Provider((ref) { ); } - return SpotifyApi.withAccessToken(authState.accessToken); + return SpotifyApi.withAccessToken(authState.asData!.value!.accessToken.value); }); diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index 8b96305f..a730c313 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -134,8 +134,11 @@ class UserPreferencesNotifier extends Notifier { void setLocalLibraryLocation(List localLibraryDirs) { //if (localLibraryDir.isEmpty) return; - setData(PreferencesTableCompanion( - localLibraryLocation: Value(localLibraryDirs))); + setData( + PreferencesTableCompanion( + localLibraryLocation: Value(localLibraryDirs), + ), + ); } void setLayoutMode(LayoutMode mode) { diff --git a/lib/services/kv_store/encrypted_kv_store.dart b/lib/services/kv_store/encrypted_kv_store.dart index d8f69690..ab4a750e 100644 --- a/lib/services/kv_store/encrypted_kv_store.dart +++ b/lib/services/kv_store/encrypted_kv_store.dart @@ -1,6 +1,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:uuid/uuid.dart'; +import 'package:spotube/utils/platform.dart'; abstract class EncryptedKvStoreService { static const _storage = FlutterSecureStorage( @@ -9,15 +10,21 @@ abstract class EncryptedKvStoreService { ), ); - static late final String _encryptionKeySync; + static String? _encryptionKeySync; static Future initialize() async { _encryptionKeySync = await encryptionKey; } - static String get encryptionKeySync => _encryptionKeySync; + static String get encryptionKeySync => _encryptionKeySync!; + + static bool get isUnsupportedPlatform => + kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak); static Future get encryptionKey async { + if (isUnsupportedPlatform) { + return KVStoreService.encryptionKey; + } try { final value = await _storage.read(key: 'encryption'); final key = const Uuid().v4(); @@ -34,10 +41,17 @@ abstract class EncryptedKvStoreService { } static Future setEncryptionKey(String key) async { + if (isUnsupportedPlatform) { + await KVStoreService.setEncryptionKey(key); + return; + } + try { await _storage.write(key: 'encryption', value: key); } catch (e) { await KVStoreService.setEncryptionKey(key); + } finally { + _encryptionKeySync = key; } } } From b9b7d5c8aad013224ba749a31dcc9627ffa76e9e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 17 Jun 2024 18:08:57 +0600 Subject: [PATCH 143/261] refactor: lastfm scrobbling to drift db --- .../heart_button/use_track_toggle_like.dart | 2 +- lib/models/database/database.dart | 2 + lib/models/database/database.g.dart | 380 ++++++++++++++++++ lib/models/database/tables/scrobbler.dart | 8 + lib/pages/lastfm_login/lastfm_login.dart | 2 +- lib/pages/settings/sections/accounts.dart | 4 +- .../proxy_playlist_provider.dart | 2 +- lib/provider/scrobbler/scrobbler.dart | 130 ++++++ lib/provider/scrobbler_provider.dart | 129 ------ 9 files changed, 525 insertions(+), 134 deletions(-) create mode 100644 lib/models/database/tables/scrobbler.dart create mode 100644 lib/provider/scrobbler/scrobbler.dart delete mode 100644 lib/provider/scrobbler_provider.dart diff --git a/lib/components/heart_button/use_track_toggle_like.dart b/lib/components/heart_button/use_track_toggle_like.dart index 2a886feb..ba5cbee1 100644 --- a/lib/components/heart_button/use_track_toggle_like.dart +++ b/lib/components/heart_button/use_track_toggle_like.dart @@ -1,7 +1,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/provider/scrobbler/scrobbler.dart'; import 'package:spotube/provider/spotify/spotify.dart'; typedef UseTrackToggleLike = ({ diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 56f72ee7..e387291a 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -22,6 +22,7 @@ part 'database.g.dart'; part 'tables/authentication.dart'; part 'tables/blacklist.dart'; part 'tables/preferences.dart'; +part 'tables/scrobbler.dart'; part 'tables/skip_segment.dart'; part 'tables/source_match.dart'; @@ -35,6 +36,7 @@ part 'typeconverters/encrypted_text.dart'; AuthenticationTable, BlacklistTable, PreferencesTable, + ScrobblerTable, SkipSegmentTable, SourceMatchTable, ], diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index 0ac7005e..6bcfbf21 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -1731,6 +1731,260 @@ class PreferencesTableCompanion extends UpdateCompanion { } } +class $ScrobblerTableTable extends ScrobblerTable + with TableInfo<$ScrobblerTableTable, ScrobblerTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ScrobblerTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime); + static const VerificationMeta _usernameMeta = + const VerificationMeta('username'); + @override + late final GeneratedColumn username = GeneratedColumn( + 'username', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _passwordHashMeta = + const VerificationMeta('passwordHash'); + @override + late final GeneratedColumn passwordHash = GeneratedColumn( + 'password_hash', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + @override + List get $columns => [id, createdAt, username, passwordHash]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'scrobbler_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + if (data.containsKey('username')) { + context.handle(_usernameMeta, + username.isAcceptableOrUnknown(data['username']!, _usernameMeta)); + } else if (isInserting) { + context.missing(_usernameMeta); + } + if (data.containsKey('password_hash')) { + context.handle( + _passwordHashMeta, + passwordHash.isAcceptableOrUnknown( + data['password_hash']!, _passwordHashMeta)); + } else if (isInserting) { + context.missing(_passwordHashMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ScrobblerTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ScrobblerTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + username: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}username'])!, + passwordHash: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}password_hash'])!, + ); + } + + @override + $ScrobblerTableTable createAlias(String alias) { + return $ScrobblerTableTable(attachedDatabase, alias); + } +} + +class ScrobblerTableData extends DataClass + implements Insertable { + final int id; + final DateTime createdAt; + final String username; + final String passwordHash; + const ScrobblerTableData( + {required this.id, + required this.createdAt, + required this.username, + required this.passwordHash}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + map['username'] = Variable(username); + map['password_hash'] = Variable(passwordHash); + return map; + } + + ScrobblerTableCompanion toCompanion(bool nullToAbsent) { + return ScrobblerTableCompanion( + id: Value(id), + createdAt: Value(createdAt), + username: Value(username), + passwordHash: Value(passwordHash), + ); + } + + factory ScrobblerTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ScrobblerTableData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + username: serializer.fromJson(json['username']), + passwordHash: serializer.fromJson(json['passwordHash']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'username': serializer.toJson(username), + 'passwordHash': serializer.toJson(passwordHash), + }; + } + + ScrobblerTableData copyWith( + {int? id, + DateTime? createdAt, + String? username, + String? passwordHash}) => + ScrobblerTableData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + username: username ?? this.username, + passwordHash: passwordHash ?? this.passwordHash, + ); + @override + String toString() { + return (StringBuffer('ScrobblerTableData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('username: $username, ') + ..write('passwordHash: $passwordHash') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, username, passwordHash); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ScrobblerTableData && + other.id == this.id && + other.createdAt == this.createdAt && + other.username == this.username && + other.passwordHash == this.passwordHash); +} + +class ScrobblerTableCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value username; + final Value passwordHash; + const ScrobblerTableCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.username = const Value.absent(), + this.passwordHash = const Value.absent(), + }); + ScrobblerTableCompanion.insert({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + required String username, + required String passwordHash, + }) : username = Value(username), + passwordHash = Value(passwordHash); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? username, + Expression? passwordHash, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (username != null) 'username': username, + if (passwordHash != null) 'password_hash': passwordHash, + }); + } + + ScrobblerTableCompanion copyWith( + {Value? id, + Value? createdAt, + Value? username, + Value? passwordHash}) { + return ScrobblerTableCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + username: username ?? this.username, + passwordHash: passwordHash ?? this.passwordHash, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (username.present) { + map['username'] = Variable(username.value); + } + if (passwordHash.present) { + map['password_hash'] = Variable(passwordHash.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ScrobblerTableCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('username: $username, ') + ..write('passwordHash: $passwordHash') + ..write(')')) + .toString(); + } +} + class $SkipSegmentTableTable extends SkipSegmentTable with TableInfo<$SkipSegmentTableTable, SkipSegmentTableData> { @override @@ -2324,6 +2578,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $BlacklistTableTable blacklistTable = $BlacklistTableTable(this); late final $PreferencesTableTable preferencesTable = $PreferencesTableTable(this); + late final $ScrobblerTableTable scrobblerTable = $ScrobblerTableTable(this); late final $SkipSegmentTableTable skipSegmentTable = $SkipSegmentTableTable(this); late final $SourceMatchTableTable sourceMatchTable = @@ -2340,6 +2595,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { authenticationTable, blacklistTable, preferencesTable, + scrobblerTable, skipSegmentTable, sourceMatchTable, uniqueBlacklist, @@ -3081,6 +3337,128 @@ class $$PreferencesTableTableOrderingComposer ColumnOrderings(column, joinBuilders: joinBuilders)); } +typedef $$ScrobblerTableTableInsertCompanionBuilder = ScrobblerTableCompanion + Function({ + Value id, + Value createdAt, + required String username, + required String passwordHash, +}); +typedef $$ScrobblerTableTableUpdateCompanionBuilder = ScrobblerTableCompanion + Function({ + Value id, + Value createdAt, + Value username, + Value passwordHash, +}); + +class $$ScrobblerTableTableTableManager extends RootTableManager< + _$AppDatabase, + $ScrobblerTableTable, + ScrobblerTableData, + $$ScrobblerTableTableFilterComposer, + $$ScrobblerTableTableOrderingComposer, + $$ScrobblerTableTableProcessedTableManager, + $$ScrobblerTableTableInsertCompanionBuilder, + $$ScrobblerTableTableUpdateCompanionBuilder> { + $$ScrobblerTableTableTableManager( + _$AppDatabase db, $ScrobblerTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$ScrobblerTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$ScrobblerTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$ScrobblerTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + Value username = const Value.absent(), + Value passwordHash = const Value.absent(), + }) => + ScrobblerTableCompanion( + id: id, + createdAt: createdAt, + username: username, + passwordHash: passwordHash, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + required String username, + required String passwordHash, + }) => + ScrobblerTableCompanion.insert( + id: id, + createdAt: createdAt, + username: username, + passwordHash: passwordHash, + ), + )); +} + +class $$ScrobblerTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $ScrobblerTableTable, + ScrobblerTableData, + $$ScrobblerTableTableFilterComposer, + $$ScrobblerTableTableOrderingComposer, + $$ScrobblerTableTableProcessedTableManager, + $$ScrobblerTableTableInsertCompanionBuilder, + $$ScrobblerTableTableUpdateCompanionBuilder> { + $$ScrobblerTableTableProcessedTableManager(super.$state); +} + +class $$ScrobblerTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $ScrobblerTableTable> { + $$ScrobblerTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get username => $state.composableBuilder( + column: $state.table.username, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get passwordHash => $state.composableBuilder( + column: $state.table.passwordHash, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); +} + +class $$ScrobblerTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $ScrobblerTableTable> { + $$ScrobblerTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get username => $state.composableBuilder( + column: $state.table.username, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get passwordHash => $state.composableBuilder( + column: $state.table.passwordHash, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + typedef $$SkipSegmentTableTableInsertCompanionBuilder = SkipSegmentTableCompanion Function({ Value id, @@ -3370,6 +3748,8 @@ class _$AppDatabaseManager { $$BlacklistTableTableTableManager(_db, _db.blacklistTable); $$PreferencesTableTableTableManager get preferencesTable => $$PreferencesTableTableTableManager(_db, _db.preferencesTable); + $$ScrobblerTableTableTableManager get scrobblerTable => + $$ScrobblerTableTableTableManager(_db, _db.scrobblerTable); $$SkipSegmentTableTableTableManager get skipSegmentTable => $$SkipSegmentTableTableTableManager(_db, _db.skipSegmentTable); $$SourceMatchTableTableTableManager get sourceMatchTable => diff --git a/lib/models/database/tables/scrobbler.dart b/lib/models/database/tables/scrobbler.dart new file mode 100644 index 00000000..481c441e --- /dev/null +++ b/lib/models/database/tables/scrobbler.dart @@ -0,0 +1,8 @@ +part of '../database.dart'; + +class ScrobblerTable extends Table { + IntColumn get id => integer().autoIncrement()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + TextColumn get username => text()(); + TextColumn get passwordHash => text().map(EncryptedTextConverter())(); +} diff --git a/lib/pages/lastfm_login/lastfm_login.dart b/lib/pages/lastfm_login/lastfm_login.dart index da2e4e13..8107e627 100644 --- a/lib/pages/lastfm_login/lastfm_login.dart +++ b/lib/pages/lastfm_login/lastfm_login.dart @@ -7,7 +7,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/dialogs/prompt_dialog.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/provider/scrobbler/scrobbler.dart'; class LastFMLoginPage extends HookConsumerWidget { static const name = "lastfm_login"; diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 1604f14b..b06a67f6 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -10,7 +10,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/provider/scrobbler/scrobbler.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -119,7 +119,7 @@ class SettingsAccountSection extends HookConsumerWidget { ), ); }), - if (scrobbler == null) + if (scrobbler.asData?.value == null) ListTile( leading: const Icon(SpotubeIcons.lastFm), title: Text(context.l10n.login_with_lastfm), diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart index d52073da..067d8d44 100644 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ b/lib/provider/proxy_playlist/proxy_playlist_provider.dart @@ -11,7 +11,7 @@ import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/scrobbler_provider.dart'; +import 'package:spotube/provider/scrobbler/scrobbler.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; diff --git a/lib/provider/scrobbler/scrobbler.dart b/lib/provider/scrobbler/scrobbler.dart new file mode 100644 index 00000000..d0b41c56 --- /dev/null +++ b/lib/provider/scrobbler/scrobbler.dart @@ -0,0 +1,130 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:scrobblenaut/scrobblenaut.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/env.dart'; +import 'package:spotube/extensions/artist_simple.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/services/logger/logger.dart'; + +class ScrobblerNotifier extends AsyncNotifier { + final StreamController _scrobbleController = + StreamController.broadcast(); + @override + build() async { + final database = ref.watch(databaseProvider); + + final loginInfo = await (database.select(database.scrobblerTable) + ..where((t) => t.id.equals(0))) + .getSingleOrNull(); + + final subscription = + database.select(database.scrobblerTable).watch().listen((event) async { + if (event.isNotEmpty) { + state = await AsyncValue.guard( + () async => Scrobblenaut( + lastFM: await LastFM.authenticateWithPasswordHash( + apiKey: Env.lastFmApiKey, + apiSecret: Env.lastFmApiSecret, + username: event.first.username, + passwordHash: event.first.passwordHash, + ), + ), + ); + } else { + state = const AsyncValue.data(null); + } + }); + + final scrobblerSubscription = + _scrobbleController.stream.listen((track) async { + try { + await state.asData?.value?.track.scrobble( + artist: track.artists!.first.name!, + track: track.name!, + album: track.album!.name!, + chosenByUser: true, + duration: track.duration, + timestamp: DateTime.now().toUtc(), + trackNumber: track.trackNumber, + ); + } catch (e, stackTrace) { + AppLogger.reportError(e, stackTrace); + } + }); + + ref.onDispose(() { + subscription.cancel(); + scrobblerSubscription.cancel(); + }); + + if (loginInfo == null) { + return null; + } + + return Scrobblenaut( + lastFM: await LastFM.authenticateWithPasswordHash( + apiKey: Env.lastFmApiKey, + apiSecret: Env.lastFmApiSecret, + username: loginInfo.username, + passwordHash: loginInfo.passwordHash, + ), + ); + } + + Future login( + String username, + String password, + ) async { + final database = ref.read(databaseProvider); + + final lastFm = await LastFM.authenticate( + apiKey: Env.lastFmApiKey, + apiSecret: Env.lastFmApiSecret, + username: username, + password: password, + ); + + if (!lastFm.isAuth) throw Exception("Invalid credentials"); + + await database.into(database.scrobblerTable).insert( + ScrobblerTableCompanion.insert( + id: const Value(0), + username: username, + passwordHash: lastFm.passwordHash!, + ), + ); + } + + Future logout() async { + state = const AsyncValue.data(null); + final database = ref.read(databaseProvider); + await database.delete(database.scrobblerTable).go(); + } + + void scrobble(Track track) { + _scrobbleController.add(track); + } + + Future love(Track track) async { + await state.asData?.value?.track.love( + artist: track.artists!.asString(), + track: track.name!, + ); + } + + Future unlove(Track track) async { + await state.asData?.value?.track.unLove( + artist: track.artists!.asString(), + track: track.name!, + ); + } +} + +final scrobblerProvider = + AsyncNotifierProvider( + () => ScrobblerNotifier(), +); diff --git a/lib/provider/scrobbler_provider.dart b/lib/provider/scrobbler_provider.dart deleted file mode 100644 index ab111ea4..00000000 --- a/lib/provider/scrobbler_provider.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:async'; - -import 'package:spotube/services/logger/logger.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:scrobblenaut/scrobblenaut.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/env.dart'; -import 'package:spotube/extensions/artist_simple.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; - -class ScrobblerState { - final String username; - final String passwordHash; - - final Scrobblenaut scrobblenaut; - - ScrobblerState({ - required this.username, - required this.passwordHash, - required this.scrobblenaut, - }); - - Map toJson() { - return { - 'username': username, - 'passwordHash': passwordHash, - }; - } -} - -class ScrobblerNotifier extends PersistedStateNotifier { - final Scrobblenaut? scrobblenaut; - - /// Directly scrobbling in set state of [ProxyPlaylistNotifier] - /// brings extra latency in playback - final StreamController _scrobbleController = - StreamController.broadcast(); - - ScrobblerNotifier() - : scrobblenaut = null, - super(null, "scrobbler", encrypted: true) { - _scrobbleController.stream.listen((track) async { - try { - await state?.scrobblenaut.track.scrobble( - artist: track.artists!.first.name!, - track: track.name!, - album: track.album!.name!, - chosenByUser: true, - duration: track.duration, - timestamp: DateTime.now().toUtc(), - trackNumber: track.trackNumber, - ); - } catch (e, stackTrace) { - AppLogger.reportError(e, stackTrace); - } - }); - } - - Future login( - String username, - String password, - ) async { - final lastFm = await LastFM.authenticate( - apiKey: Env.lastFmApiKey, - apiSecret: Env.lastFmApiSecret, - username: username, - password: password, - ); - if (!lastFm.isAuth) throw Exception("Invalid credentials"); - state = ScrobblerState( - username: username, - passwordHash: lastFm.passwordHash!, - scrobblenaut: Scrobblenaut(lastFM: lastFm), - ); - } - - Future logout() async { - state = null; - } - - void scrobble(Track track) { - _scrobbleController.add(track); - } - - Future love(Track track) async { - await state?.scrobblenaut.track.love( - artist: track.artists!.asString(), - track: track.name!, - ); - } - - Future unlove(Track track) async { - await state?.scrobblenaut.track.unLove( - artist: track.artists!.asString(), - track: track.name!, - ); - } - - @override - FutureOr fromJson(Map json) async { - if (json.isEmpty) { - return null; - } - - return ScrobblerState( - username: json['username'], - passwordHash: json['passwordHash'], - scrobblenaut: Scrobblenaut( - lastFM: await LastFM.authenticateWithPasswordHash( - apiKey: Env.lastFmApiKey, - apiSecret: Env.lastFmApiSecret, - username: json["username"], - passwordHash: json["passwordHash"], - ), - ), - ); - } - - @override - Map toJson() { - return state?.toJson() ?? {}; - } -} - -final scrobblerProvider = - StateNotifierProvider( - (ref) => ScrobblerNotifier(), -); From 5936f08a92182a4cc3a0e72c2c044d4e480d2158 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 17 Jun 2024 18:13:41 +0600 Subject: [PATCH 144/261] refactor(volumeProvider): use notifier and kvstore for persistence --- lib/provider/volume_provider.dart | 29 ++++++++++------------------- lib/services/kv_store/kv_store.dart | 4 ++++ 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/lib/provider/volume_provider.dart b/lib/provider/volume_provider.dart index 464b5e42..ddd38fd9 100644 --- a/lib/provider/volume_provider.dart +++ b/lib/provider/volume_provider.dart @@ -2,31 +2,22 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/services/kv_store/kv_store.dart'; -class VolumeProvider extends PersistedStateNotifier { - VolumeProvider() : super(1, 'volume'); +class VolumeProvider extends Notifier { + VolumeProvider(); + + @override + build() { + return KVStoreService.volume; + } Future setVolume(double volume) async { state = volume; await audioPlayer.setVolume(volume); - } - - @override - FutureOr onInit() async { - await audioPlayer.setVolume(state); - } - - @override - FutureOr fromJson(Map json) { - return json['volume'] as double? ?? 0.0; - } - - @override - Map toJson() { - return {'volume': state}; + KVStoreService.setVolume(volume); } } final volumeProvider = - StateNotifierProvider((ref) => VolumeProvider()); + NotifierProvider(() => VolumeProvider()); diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index 6b19c032..2707ea4d 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -78,4 +78,8 @@ abstract class KVStoreService { static Future setIVKey(IV iv) async { await sharedPreferences.setString('iv', iv.base64); } + + static double get volume => sharedPreferences.getDouble('volume') ?? 1.0; + static Future setVolume(double value) async => + await sharedPreferences.setDouble('volume', value); } From 59041a2948b7dcaade4bb8cc9328d06a2c25f897 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 20 Jun 2024 23:30:41 +0600 Subject: [PATCH 145/261] chore: use .value for scrobble encrypted text --- lib/models/database/database.g.dart | 67 +++++++++++++++------------ lib/provider/scrobbler/scrobbler.dart | 6 +-- 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index 6bcfbf21..6f899066 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -1763,9 +1763,12 @@ class $ScrobblerTableTable extends ScrobblerTable static const VerificationMeta _passwordHashMeta = const VerificationMeta('passwordHash'); @override - late final GeneratedColumn passwordHash = GeneratedColumn( - 'password_hash', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumnWithTypeConverter + passwordHash = GeneratedColumn( + 'password_hash', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter( + $ScrobblerTableTable.$converterpasswordHash); @override List get $columns => [id, createdAt, username, passwordHash]; @override @@ -1791,14 +1794,7 @@ class $ScrobblerTableTable extends ScrobblerTable } else if (isInserting) { context.missing(_usernameMeta); } - if (data.containsKey('password_hash')) { - context.handle( - _passwordHashMeta, - passwordHash.isAcceptableOrUnknown( - data['password_hash']!, _passwordHashMeta)); - } else if (isInserting) { - context.missing(_passwordHashMeta); - } + context.handle(_passwordHashMeta, const VerificationResult.success()); return context; } @@ -1814,8 +1810,9 @@ class $ScrobblerTableTable extends ScrobblerTable .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, username: attachedDatabase.typeMapping .read(DriftSqlType.string, data['${effectivePrefix}username'])!, - passwordHash: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}password_hash'])!, + passwordHash: $ScrobblerTableTable.$converterpasswordHash.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}password_hash'])!), ); } @@ -1823,6 +1820,9 @@ class $ScrobblerTableTable extends ScrobblerTable $ScrobblerTableTable createAlias(String alias) { return $ScrobblerTableTable(attachedDatabase, alias); } + + static TypeConverter $converterpasswordHash = + EncryptedTextConverter(); } class ScrobblerTableData extends DataClass @@ -1830,7 +1830,7 @@ class ScrobblerTableData extends DataClass final int id; final DateTime createdAt; final String username; - final String passwordHash; + final DecryptedText passwordHash; const ScrobblerTableData( {required this.id, required this.createdAt, @@ -1842,7 +1842,10 @@ class ScrobblerTableData extends DataClass map['id'] = Variable(id); map['created_at'] = Variable(createdAt); map['username'] = Variable(username); - map['password_hash'] = Variable(passwordHash); + { + map['password_hash'] = Variable( + $ScrobblerTableTable.$converterpasswordHash.toSql(passwordHash)); + } return map; } @@ -1862,7 +1865,7 @@ class ScrobblerTableData extends DataClass id: serializer.fromJson(json['id']), createdAt: serializer.fromJson(json['createdAt']), username: serializer.fromJson(json['username']), - passwordHash: serializer.fromJson(json['passwordHash']), + passwordHash: serializer.fromJson(json['passwordHash']), ); } @override @@ -1872,7 +1875,7 @@ class ScrobblerTableData extends DataClass 'id': serializer.toJson(id), 'createdAt': serializer.toJson(createdAt), 'username': serializer.toJson(username), - 'passwordHash': serializer.toJson(passwordHash), + 'passwordHash': serializer.toJson(passwordHash), }; } @@ -1880,7 +1883,7 @@ class ScrobblerTableData extends DataClass {int? id, DateTime? createdAt, String? username, - String? passwordHash}) => + DecryptedText? passwordHash}) => ScrobblerTableData( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -1914,7 +1917,7 @@ class ScrobblerTableCompanion extends UpdateCompanion { final Value id; final Value createdAt; final Value username; - final Value passwordHash; + final Value passwordHash; const ScrobblerTableCompanion({ this.id = const Value.absent(), this.createdAt = const Value.absent(), @@ -1925,7 +1928,7 @@ class ScrobblerTableCompanion extends UpdateCompanion { this.id = const Value.absent(), this.createdAt = const Value.absent(), required String username, - required String passwordHash, + required DecryptedText passwordHash, }) : username = Value(username), passwordHash = Value(passwordHash); static Insertable custom({ @@ -1946,7 +1949,7 @@ class ScrobblerTableCompanion extends UpdateCompanion { {Value? id, Value? createdAt, Value? username, - Value? passwordHash}) { + Value? passwordHash}) { return ScrobblerTableCompanion( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -1968,7 +1971,9 @@ class ScrobblerTableCompanion extends UpdateCompanion { map['username'] = Variable(username.value); } if (passwordHash.present) { - map['password_hash'] = Variable(passwordHash.value); + map['password_hash'] = Variable($ScrobblerTableTable + .$converterpasswordHash + .toSql(passwordHash.value)); } return map; } @@ -3342,14 +3347,14 @@ typedef $$ScrobblerTableTableInsertCompanionBuilder = ScrobblerTableCompanion Value id, Value createdAt, required String username, - required String passwordHash, + required DecryptedText passwordHash, }); typedef $$ScrobblerTableTableUpdateCompanionBuilder = ScrobblerTableCompanion Function({ Value id, Value createdAt, Value username, - Value passwordHash, + Value passwordHash, }); class $$ScrobblerTableTableTableManager extends RootTableManager< @@ -3376,7 +3381,7 @@ class $$ScrobblerTableTableTableManager extends RootTableManager< Value id = const Value.absent(), Value createdAt = const Value.absent(), Value username = const Value.absent(), - Value passwordHash = const Value.absent(), + Value passwordHash = const Value.absent(), }) => ScrobblerTableCompanion( id: id, @@ -3388,7 +3393,7 @@ class $$ScrobblerTableTableTableManager extends RootTableManager< Value id = const Value.absent(), Value createdAt = const Value.absent(), required String username, - required String passwordHash, + required DecryptedText passwordHash, }) => ScrobblerTableCompanion.insert( id: id, @@ -3429,10 +3434,12 @@ class $$ScrobblerTableTableFilterComposer builder: (column, joinBuilders) => ColumnFilters(column, joinBuilders: joinBuilders)); - ColumnFilters get passwordHash => $state.composableBuilder( - column: $state.table.passwordHash, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnWithTypeConverterFilters + get passwordHash => $state.composableBuilder( + column: $state.table.passwordHash, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); } class $$ScrobblerTableTableOrderingComposer diff --git a/lib/provider/scrobbler/scrobbler.dart b/lib/provider/scrobbler/scrobbler.dart index d0b41c56..76559d69 100644 --- a/lib/provider/scrobbler/scrobbler.dart +++ b/lib/provider/scrobbler/scrobbler.dart @@ -30,7 +30,7 @@ class ScrobblerNotifier extends AsyncNotifier { apiKey: Env.lastFmApiKey, apiSecret: Env.lastFmApiSecret, username: event.first.username, - passwordHash: event.first.passwordHash, + passwordHash: event.first.passwordHash.value, ), ), ); @@ -70,7 +70,7 @@ class ScrobblerNotifier extends AsyncNotifier { apiKey: Env.lastFmApiKey, apiSecret: Env.lastFmApiSecret, username: loginInfo.username, - passwordHash: loginInfo.passwordHash, + passwordHash: loginInfo.passwordHash.value, ), ); } @@ -94,7 +94,7 @@ class ScrobblerNotifier extends AsyncNotifier { ScrobblerTableCompanion.insert( id: const Value(0), username: username, - passwordHash: lastFm.passwordHash!, + passwordHash: DecryptedText(lastFm.passwordHash!), ), ); } From f79fedefd48d4ac034960fcdc257e6be0a746022 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 23 Jun 2024 12:23:28 +0600 Subject: [PATCH 146/261] chore: create new audio player centric playback notifier with drift persistence --- lib/models/connect/connect.dart | 4 +- lib/models/connect/ws_event.dart | 12 +- lib/models/database/database.dart | 6 + lib/models/database/database.g.dart | 1305 +++++++++++++++++ .../database/tables/audio_player_state.dart | 27 + lib/models/database/typeconverters/map.dart | 15 + lib/modules/player/player_controls.dart | 27 +- lib/modules/player/use_progress.dart | 20 +- lib/pages/connect/control/control.dart | 23 +- lib/provider/audio_player/audio_player.dart | 225 +++ lib/provider/audio_player/state.dart | 42 + lib/provider/connect/connect.dart | 10 +- lib/provider/tray_manager/tray_menu.dart | 8 +- lib/services/audio_player/audio_player.dart | 18 +- .../audio_player/audio_player_impl.dart | 9 +- .../audio_players_streams_mixin.dart | 6 +- lib/services/audio_player/loop_mode.dart | 90 -- .../audio_services/mobile_audio_service.dart | 23 +- 18 files changed, 1694 insertions(+), 176 deletions(-) create mode 100644 lib/models/database/tables/audio_player_state.dart create mode 100644 lib/models/database/typeconverters/map.dart create mode 100644 lib/provider/audio_player/audio_player.dart create mode 100644 lib/provider/audio_player/state.dart delete mode 100644 lib/services/audio_player/loop_mode.dart diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart index 28386050..0a06be32 100644 --- a/lib/models/connect/connect.dart +++ b/lib/models/connect/connect.dart @@ -4,9 +4,9 @@ import 'dart:async'; import 'dart:convert'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:spotify/spotify.dart'; +import 'package:media_kit/media_kit.dart' hide Track; +import 'package:spotify/spotify.dart' hide Playlist; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; part 'connect.freezed.dart'; part 'connect.g.dart'; diff --git a/lib/models/connect/ws_event.dart b/lib/models/connect/ws_event.dart index 2d7213b1..c3c29e76 100644 --- a/lib/models/connect/ws_event.dart +++ b/lib/models/connect/ws_event.dart @@ -183,7 +183,7 @@ class WebSocketEvent { if (type == WsEvent.loop) { await callback( WebSocketLoopEvent( - PlaybackLoopMode.fromString(data as String), + PlaylistMode.values.firstWhere((e) => e.name == data as String), ), ); } @@ -224,12 +224,16 @@ class WebSocketEvent { } } -class WebSocketLoopEvent extends WebSocketEvent { - WebSocketLoopEvent(PlaybackLoopMode data) : super(WsEvent.loop, data); +class WebSocketLoopEvent extends WebSocketEvent { + WebSocketLoopEvent(PlaylistMode data) : super(WsEvent.loop, data); WebSocketLoopEvent.fromJson(Map json) : super( - WsEvent.loop, PlaybackLoopMode.fromString(json["data"] as String)); + WsEvent.loop, + PlaylistMode.values.firstWhere( + (e) => e.name == json["data"] as String, + ), + ); @override String toJson() { diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index e387291a..98dc22dc 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:drift/drift.dart'; import 'package:encrypt/encrypt.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; @@ -25,11 +26,13 @@ part 'tables/preferences.dart'; part 'tables/scrobbler.dart'; part 'tables/skip_segment.dart'; part 'tables/source_match.dart'; +part 'tables/audio_player_state.dart'; part 'typeconverters/color.dart'; part 'typeconverters/locale.dart'; part 'typeconverters/string_list.dart'; part 'typeconverters/encrypted_text.dart'; +part 'typeconverters/map.dart'; @DriftDatabase( tables: [ @@ -39,6 +42,9 @@ part 'typeconverters/encrypted_text.dart'; ScrobblerTable, SkipSegmentTable, SourceMatchTable, + AudioPlayerStateTable, + PlaylistTable, + PlaylistMediaTable, ], ) class AppDatabase extends _$AppDatabase { diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index 6f899066..ca9d6d97 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -2575,6 +2575,839 @@ class SourceMatchTableCompanion extends UpdateCompanion { } } +class $AudioPlayerStateTableTable extends AudioPlayerStateTable + with TableInfo<$AudioPlayerStateTableTable, AudioPlayerStateTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $AudioPlayerStateTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _playingMeta = + const VerificationMeta('playing'); + @override + late final GeneratedColumn playing = GeneratedColumn( + 'playing', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("playing" IN (0, 1))')); + static const VerificationMeta _volumeMeta = const VerificationMeta('volume'); + @override + late final GeneratedColumn volume = GeneratedColumn( + 'volume', aliasedName, false, + type: DriftSqlType.double, requiredDuringInsert: true); + static const VerificationMeta _loopModeMeta = + const VerificationMeta('loopMode'); + @override + late final GeneratedColumnWithTypeConverter loopMode = + GeneratedColumn('loop_mode', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter( + $AudioPlayerStateTableTable.$converterloopMode); + static const VerificationMeta _shuffledMeta = + const VerificationMeta('shuffled'); + @override + late final GeneratedColumn shuffled = GeneratedColumn( + 'shuffled', aliasedName, false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('CHECK ("shuffled" IN (0, 1))')); + @override + List get $columns => + [id, playing, volume, loopMode, shuffled]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'audio_player_state_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('playing')) { + context.handle(_playingMeta, + playing.isAcceptableOrUnknown(data['playing']!, _playingMeta)); + } else if (isInserting) { + context.missing(_playingMeta); + } + if (data.containsKey('volume')) { + context.handle(_volumeMeta, + volume.isAcceptableOrUnknown(data['volume']!, _volumeMeta)); + } else if (isInserting) { + context.missing(_volumeMeta); + } + context.handle(_loopModeMeta, const VerificationResult.success()); + if (data.containsKey('shuffled')) { + context.handle(_shuffledMeta, + shuffled.isAcceptableOrUnknown(data['shuffled']!, _shuffledMeta)); + } else if (isInserting) { + context.missing(_shuffledMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + AudioPlayerStateTableData map(Map data, + {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AudioPlayerStateTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + playing: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}playing'])!, + volume: attachedDatabase.typeMapping + .read(DriftSqlType.double, data['${effectivePrefix}volume'])!, + loopMode: $AudioPlayerStateTableTable.$converterloopMode.fromSql( + attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}loop_mode'])!), + shuffled: attachedDatabase.typeMapping + .read(DriftSqlType.bool, data['${effectivePrefix}shuffled'])!, + ); + } + + @override + $AudioPlayerStateTableTable createAlias(String alias) { + return $AudioPlayerStateTableTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 $converterloopMode = + const EnumNameConverter(PlaylistMode.values); +} + +class AudioPlayerStateTableData extends DataClass + implements Insertable { + final int id; + final bool playing; + final double volume; + final PlaylistMode loopMode; + final bool shuffled; + const AudioPlayerStateTableData( + {required this.id, + required this.playing, + required this.volume, + required this.loopMode, + required this.shuffled}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['playing'] = Variable(playing); + map['volume'] = Variable(volume); + { + map['loop_mode'] = Variable( + $AudioPlayerStateTableTable.$converterloopMode.toSql(loopMode)); + } + map['shuffled'] = Variable(shuffled); + return map; + } + + AudioPlayerStateTableCompanion toCompanion(bool nullToAbsent) { + return AudioPlayerStateTableCompanion( + id: Value(id), + playing: Value(playing), + volume: Value(volume), + loopMode: Value(loopMode), + shuffled: Value(shuffled), + ); + } + + factory AudioPlayerStateTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AudioPlayerStateTableData( + id: serializer.fromJson(json['id']), + playing: serializer.fromJson(json['playing']), + volume: serializer.fromJson(json['volume']), + loopMode: $AudioPlayerStateTableTable.$converterloopMode + .fromJson(serializer.fromJson(json['loopMode'])), + shuffled: serializer.fromJson(json['shuffled']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'playing': serializer.toJson(playing), + 'volume': serializer.toJson(volume), + 'loopMode': serializer.toJson( + $AudioPlayerStateTableTable.$converterloopMode.toJson(loopMode)), + 'shuffled': serializer.toJson(shuffled), + }; + } + + AudioPlayerStateTableData copyWith( + {int? id, + bool? playing, + double? volume, + PlaylistMode? loopMode, + bool? shuffled}) => + AudioPlayerStateTableData( + id: id ?? this.id, + playing: playing ?? this.playing, + volume: volume ?? this.volume, + loopMode: loopMode ?? this.loopMode, + shuffled: shuffled ?? this.shuffled, + ); + @override + String toString() { + return (StringBuffer('AudioPlayerStateTableData(') + ..write('id: $id, ') + ..write('playing: $playing, ') + ..write('volume: $volume, ') + ..write('loopMode: $loopMode, ') + ..write('shuffled: $shuffled') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, playing, volume, loopMode, shuffled); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AudioPlayerStateTableData && + other.id == this.id && + other.playing == this.playing && + other.volume == this.volume && + other.loopMode == this.loopMode && + other.shuffled == this.shuffled); +} + +class AudioPlayerStateTableCompanion + extends UpdateCompanion { + final Value id; + final Value playing; + final Value volume; + final Value loopMode; + final Value shuffled; + const AudioPlayerStateTableCompanion({ + this.id = const Value.absent(), + this.playing = const Value.absent(), + this.volume = const Value.absent(), + this.loopMode = const Value.absent(), + this.shuffled = const Value.absent(), + }); + AudioPlayerStateTableCompanion.insert({ + this.id = const Value.absent(), + required bool playing, + required double volume, + required PlaylistMode loopMode, + required bool shuffled, + }) : playing = Value(playing), + volume = Value(volume), + loopMode = Value(loopMode), + shuffled = Value(shuffled); + static Insertable custom({ + Expression? id, + Expression? playing, + Expression? volume, + Expression? loopMode, + Expression? shuffled, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (playing != null) 'playing': playing, + if (volume != null) 'volume': volume, + if (loopMode != null) 'loop_mode': loopMode, + if (shuffled != null) 'shuffled': shuffled, + }); + } + + AudioPlayerStateTableCompanion copyWith( + {Value? id, + Value? playing, + Value? volume, + Value? loopMode, + Value? shuffled}) { + return AudioPlayerStateTableCompanion( + id: id ?? this.id, + playing: playing ?? this.playing, + volume: volume ?? this.volume, + loopMode: loopMode ?? this.loopMode, + shuffled: shuffled ?? this.shuffled, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (playing.present) { + map['playing'] = Variable(playing.value); + } + if (volume.present) { + map['volume'] = Variable(volume.value); + } + if (loopMode.present) { + map['loop_mode'] = Variable( + $AudioPlayerStateTableTable.$converterloopMode.toSql(loopMode.value)); + } + if (shuffled.present) { + map['shuffled'] = Variable(shuffled.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AudioPlayerStateTableCompanion(') + ..write('id: $id, ') + ..write('playing: $playing, ') + ..write('volume: $volume, ') + ..write('loopMode: $loopMode, ') + ..write('shuffled: $shuffled') + ..write(')')) + .toString(); + } +} + +class $PlaylistTableTable extends PlaylistTable + with TableInfo<$PlaylistTableTable, PlaylistTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PlaylistTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _audioPlayerStateIdMeta = + const VerificationMeta('audioPlayerStateId'); + @override + late final GeneratedColumn audioPlayerStateId = GeneratedColumn( + 'audio_player_state_id', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'REFERENCES audio_player_state_table (id)')); + static const VerificationMeta _indexMeta = const VerificationMeta('index'); + @override + late final GeneratedColumn index = GeneratedColumn( + 'index', aliasedName, false, + type: DriftSqlType.int, requiredDuringInsert: true); + @override + List get $columns => [id, audioPlayerStateId, index]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'playlist_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('audio_player_state_id')) { + context.handle( + _audioPlayerStateIdMeta, + audioPlayerStateId.isAcceptableOrUnknown( + data['audio_player_state_id']!, _audioPlayerStateIdMeta)); + } else if (isInserting) { + context.missing(_audioPlayerStateIdMeta); + } + if (data.containsKey('index')) { + context.handle( + _indexMeta, index.isAcceptableOrUnknown(data['index']!, _indexMeta)); + } else if (isInserting) { + context.missing(_indexMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PlaylistTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PlaylistTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + audioPlayerStateId: attachedDatabase.typeMapping.read( + DriftSqlType.int, data['${effectivePrefix}audio_player_state_id'])!, + index: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}index'])!, + ); + } + + @override + $PlaylistTableTable createAlias(String alias) { + return $PlaylistTableTable(attachedDatabase, alias); + } +} + +class PlaylistTableData extends DataClass + implements Insertable { + final int id; + final int audioPlayerStateId; + final int index; + const PlaylistTableData( + {required this.id, + required this.audioPlayerStateId, + required this.index}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['audio_player_state_id'] = Variable(audioPlayerStateId); + map['index'] = Variable(index); + return map; + } + + PlaylistTableCompanion toCompanion(bool nullToAbsent) { + return PlaylistTableCompanion( + id: Value(id), + audioPlayerStateId: Value(audioPlayerStateId), + index: Value(index), + ); + } + + factory PlaylistTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PlaylistTableData( + id: serializer.fromJson(json['id']), + audioPlayerStateId: serializer.fromJson(json['audioPlayerStateId']), + index: serializer.fromJson(json['index']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'audioPlayerStateId': serializer.toJson(audioPlayerStateId), + 'index': serializer.toJson(index), + }; + } + + PlaylistTableData copyWith({int? id, int? audioPlayerStateId, int? index}) => + PlaylistTableData( + id: id ?? this.id, + audioPlayerStateId: audioPlayerStateId ?? this.audioPlayerStateId, + index: index ?? this.index, + ); + @override + String toString() { + return (StringBuffer('PlaylistTableData(') + ..write('id: $id, ') + ..write('audioPlayerStateId: $audioPlayerStateId, ') + ..write('index: $index') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, audioPlayerStateId, index); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PlaylistTableData && + other.id == this.id && + other.audioPlayerStateId == this.audioPlayerStateId && + other.index == this.index); +} + +class PlaylistTableCompanion extends UpdateCompanion { + final Value id; + final Value audioPlayerStateId; + final Value index; + const PlaylistTableCompanion({ + this.id = const Value.absent(), + this.audioPlayerStateId = const Value.absent(), + this.index = const Value.absent(), + }); + PlaylistTableCompanion.insert({ + this.id = const Value.absent(), + required int audioPlayerStateId, + required int index, + }) : audioPlayerStateId = Value(audioPlayerStateId), + index = Value(index); + static Insertable custom({ + Expression? id, + Expression? audioPlayerStateId, + Expression? index, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (audioPlayerStateId != null) + 'audio_player_state_id': audioPlayerStateId, + if (index != null) 'index': index, + }); + } + + PlaylistTableCompanion copyWith( + {Value? id, Value? audioPlayerStateId, Value? index}) { + return PlaylistTableCompanion( + id: id ?? this.id, + audioPlayerStateId: audioPlayerStateId ?? this.audioPlayerStateId, + index: index ?? this.index, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (audioPlayerStateId.present) { + map['audio_player_state_id'] = Variable(audioPlayerStateId.value); + } + if (index.present) { + map['index'] = Variable(index.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PlaylistTableCompanion(') + ..write('id: $id, ') + ..write('audioPlayerStateId: $audioPlayerStateId, ') + ..write('index: $index') + ..write(')')) + .toString(); + } +} + +class $PlaylistMediaTableTable extends PlaylistMediaTable + with TableInfo<$PlaylistMediaTableTable, PlaylistMediaTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PlaylistMediaTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _playlistIdMeta = + const VerificationMeta('playlistId'); + @override + late final GeneratedColumn playlistId = GeneratedColumn( + 'playlist_id', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + defaultConstraints: + GeneratedColumn.constraintIsAlways('REFERENCES playlist_table (id)')); + static const VerificationMeta _uriMeta = const VerificationMeta('uri'); + @override + late final GeneratedColumn uri = GeneratedColumn( + 'uri', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _extrasMeta = const VerificationMeta('extras'); + @override + late final GeneratedColumnWithTypeConverter?, String> + extras = GeneratedColumn('extras', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false) + .withConverter?>( + $PlaylistMediaTableTable.$converterextrasn); + static const VerificationMeta _httpHeadersMeta = + const VerificationMeta('httpHeaders'); + @override + late final GeneratedColumnWithTypeConverter?, String> + httpHeaders = GeneratedColumn('http_headers', aliasedName, true, + type: DriftSqlType.string, requiredDuringInsert: false) + .withConverter?>( + $PlaylistMediaTableTable.$converterhttpHeadersn); + @override + List get $columns => + [id, playlistId, uri, extras, httpHeaders]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'playlist_media_table'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('playlist_id')) { + context.handle( + _playlistIdMeta, + playlistId.isAcceptableOrUnknown( + data['playlist_id']!, _playlistIdMeta)); + } else if (isInserting) { + context.missing(_playlistIdMeta); + } + if (data.containsKey('uri')) { + context.handle( + _uriMeta, uri.isAcceptableOrUnknown(data['uri']!, _uriMeta)); + } else if (isInserting) { + context.missing(_uriMeta); + } + context.handle(_extrasMeta, const VerificationResult.success()); + context.handle(_httpHeadersMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PlaylistMediaTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PlaylistMediaTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + playlistId: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}playlist_id'])!, + uri: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}uri'])!, + extras: $PlaylistMediaTableTable.$converterextrasn.fromSql( + attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}extras'])), + httpHeaders: $PlaylistMediaTableTable.$converterhttpHeadersn.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}http_headers'])), + ); + } + + @override + $PlaylistMediaTableTable createAlias(String alias) { + return $PlaylistMediaTableTable(attachedDatabase, alias); + } + + static TypeConverter, String> $converterextras = + const MapTypeConverter(); + static TypeConverter?, String?> $converterextrasn = + NullAwareTypeConverter.wrap($converterextras); + static TypeConverter, String> $converterhttpHeaders = + const MapTypeConverter(); + static TypeConverter?, String?> $converterhttpHeadersn = + NullAwareTypeConverter.wrap($converterhttpHeaders); +} + +class PlaylistMediaTableData extends DataClass + implements Insertable { + final int id; + final int playlistId; + final String uri; + final Map? extras; + final Map? httpHeaders; + const PlaylistMediaTableData( + {required this.id, + required this.playlistId, + required this.uri, + this.extras, + this.httpHeaders}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['playlist_id'] = Variable(playlistId); + map['uri'] = Variable(uri); + if (!nullToAbsent || extras != null) { + map['extras'] = Variable( + $PlaylistMediaTableTable.$converterextrasn.toSql(extras)); + } + if (!nullToAbsent || httpHeaders != null) { + map['http_headers'] = Variable( + $PlaylistMediaTableTable.$converterhttpHeadersn.toSql(httpHeaders)); + } + return map; + } + + PlaylistMediaTableCompanion toCompanion(bool nullToAbsent) { + return PlaylistMediaTableCompanion( + id: Value(id), + playlistId: Value(playlistId), + uri: Value(uri), + extras: + extras == null && nullToAbsent ? const Value.absent() : Value(extras), + httpHeaders: httpHeaders == null && nullToAbsent + ? const Value.absent() + : Value(httpHeaders), + ); + } + + factory PlaylistMediaTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return PlaylistMediaTableData( + id: serializer.fromJson(json['id']), + playlistId: serializer.fromJson(json['playlistId']), + uri: serializer.fromJson(json['uri']), + extras: serializer.fromJson?>(json['extras']), + httpHeaders: + serializer.fromJson?>(json['httpHeaders']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'playlistId': serializer.toJson(playlistId), + 'uri': serializer.toJson(uri), + 'extras': serializer.toJson?>(extras), + 'httpHeaders': serializer.toJson?>(httpHeaders), + }; + } + + PlaylistMediaTableData copyWith( + {int? id, + int? playlistId, + String? uri, + Value?> extras = const Value.absent(), + Value?> httpHeaders = const Value.absent()}) => + PlaylistMediaTableData( + id: id ?? this.id, + playlistId: playlistId ?? this.playlistId, + uri: uri ?? this.uri, + extras: extras.present ? extras.value : this.extras, + httpHeaders: httpHeaders.present ? httpHeaders.value : this.httpHeaders, + ); + @override + String toString() { + return (StringBuffer('PlaylistMediaTableData(') + ..write('id: $id, ') + ..write('playlistId: $playlistId, ') + ..write('uri: $uri, ') + ..write('extras: $extras, ') + ..write('httpHeaders: $httpHeaders') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, playlistId, uri, extras, httpHeaders); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is PlaylistMediaTableData && + other.id == this.id && + other.playlistId == this.playlistId && + other.uri == this.uri && + other.extras == this.extras && + other.httpHeaders == this.httpHeaders); +} + +class PlaylistMediaTableCompanion + extends UpdateCompanion { + final Value id; + final Value playlistId; + final Value uri; + final Value?> extras; + final Value?> httpHeaders; + const PlaylistMediaTableCompanion({ + this.id = const Value.absent(), + this.playlistId = const Value.absent(), + this.uri = const Value.absent(), + this.extras = const Value.absent(), + this.httpHeaders = const Value.absent(), + }); + PlaylistMediaTableCompanion.insert({ + this.id = const Value.absent(), + required int playlistId, + required String uri, + this.extras = const Value.absent(), + this.httpHeaders = const Value.absent(), + }) : playlistId = Value(playlistId), + uri = Value(uri); + static Insertable custom({ + Expression? id, + Expression? playlistId, + Expression? uri, + Expression? extras, + Expression? httpHeaders, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (playlistId != null) 'playlist_id': playlistId, + if (uri != null) 'uri': uri, + if (extras != null) 'extras': extras, + if (httpHeaders != null) 'http_headers': httpHeaders, + }); + } + + PlaylistMediaTableCompanion copyWith( + {Value? id, + Value? playlistId, + Value? uri, + Value?>? extras, + Value?>? httpHeaders}) { + return PlaylistMediaTableCompanion( + id: id ?? this.id, + playlistId: playlistId ?? this.playlistId, + uri: uri ?? this.uri, + extras: extras ?? this.extras, + httpHeaders: httpHeaders ?? this.httpHeaders, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (playlistId.present) { + map['playlist_id'] = Variable(playlistId.value); + } + if (uri.present) { + map['uri'] = Variable(uri.value); + } + if (extras.present) { + map['extras'] = Variable( + $PlaylistMediaTableTable.$converterextrasn.toSql(extras.value)); + } + if (httpHeaders.present) { + map['http_headers'] = Variable($PlaylistMediaTableTable + .$converterhttpHeadersn + .toSql(httpHeaders.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('PlaylistMediaTableCompanion(') + ..write('id: $id, ') + ..write('playlistId: $playlistId, ') + ..write('uri: $uri, ') + ..write('extras: $extras, ') + ..write('httpHeaders: $httpHeaders') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); _$AppDatabaseManager get managers => _$AppDatabaseManager(this); @@ -2588,6 +3421,11 @@ abstract class _$AppDatabase extends GeneratedDatabase { $SkipSegmentTableTable(this); late final $SourceMatchTableTable sourceMatchTable = $SourceMatchTableTable(this); + late final $AudioPlayerStateTableTable audioPlayerStateTable = + $AudioPlayerStateTableTable(this); + late final $PlaylistTableTable playlistTable = $PlaylistTableTable(this); + late final $PlaylistMediaTableTable playlistMediaTable = + $PlaylistMediaTableTable(this); late final Index uniqueBlacklist = Index('unique_blacklist', 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); late final Index uniqTrackMatch = Index('uniq_track_match', @@ -2603,6 +3441,9 @@ abstract class _$AppDatabase extends GeneratedDatabase { scrobblerTable, skipSegmentTable, sourceMatchTable, + audioPlayerStateTable, + playlistTable, + playlistMediaTable, uniqueBlacklist, uniqTrackMatch ]; @@ -3746,6 +4587,464 @@ class $$SourceMatchTableTableOrderingComposer ColumnOrderings(column, joinBuilders: joinBuilders)); } +typedef $$AudioPlayerStateTableTableInsertCompanionBuilder + = AudioPlayerStateTableCompanion Function({ + Value id, + required bool playing, + required double volume, + required PlaylistMode loopMode, + required bool shuffled, +}); +typedef $$AudioPlayerStateTableTableUpdateCompanionBuilder + = AudioPlayerStateTableCompanion Function({ + Value id, + Value playing, + Value volume, + Value loopMode, + Value shuffled, +}); + +class $$AudioPlayerStateTableTableTableManager extends RootTableManager< + _$AppDatabase, + $AudioPlayerStateTableTable, + AudioPlayerStateTableData, + $$AudioPlayerStateTableTableFilterComposer, + $$AudioPlayerStateTableTableOrderingComposer, + $$AudioPlayerStateTableTableProcessedTableManager, + $$AudioPlayerStateTableTableInsertCompanionBuilder, + $$AudioPlayerStateTableTableUpdateCompanionBuilder> { + $$AudioPlayerStateTableTableTableManager( + _$AppDatabase db, $AudioPlayerStateTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $$AudioPlayerStateTableTableFilterComposer( + ComposerState(db, table)), + orderingComposer: $$AudioPlayerStateTableTableOrderingComposer( + ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$AudioPlayerStateTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value playing = const Value.absent(), + Value volume = const Value.absent(), + Value loopMode = const Value.absent(), + Value shuffled = const Value.absent(), + }) => + AudioPlayerStateTableCompanion( + id: id, + playing: playing, + volume: volume, + loopMode: loopMode, + shuffled: shuffled, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required bool playing, + required double volume, + required PlaylistMode loopMode, + required bool shuffled, + }) => + AudioPlayerStateTableCompanion.insert( + id: id, + playing: playing, + volume: volume, + loopMode: loopMode, + shuffled: shuffled, + ), + )); +} + +class $$AudioPlayerStateTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $AudioPlayerStateTableTable, + AudioPlayerStateTableData, + $$AudioPlayerStateTableTableFilterComposer, + $$AudioPlayerStateTableTableOrderingComposer, + $$AudioPlayerStateTableTableProcessedTableManager, + $$AudioPlayerStateTableTableInsertCompanionBuilder, + $$AudioPlayerStateTableTableUpdateCompanionBuilder> { + $$AudioPlayerStateTableTableProcessedTableManager(super.$state); +} + +class $$AudioPlayerStateTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $AudioPlayerStateTableTable> { + $$AudioPlayerStateTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get playing => $state.composableBuilder( + column: $state.table.playing, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get volume => $state.composableBuilder( + column: $state.table.volume, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get loopMode => $state.composableBuilder( + column: $state.table.loopMode, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get shuffled => $state.composableBuilder( + column: $state.table.shuffled, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ComposableFilter playlistTableRefs( + ComposableFilter Function($$PlaylistTableTableFilterComposer f) f) { + final $$PlaylistTableTableFilterComposer composer = $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $state.db.playlistTable, + getReferencedColumn: (t) => t.audioPlayerStateId, + builder: (joinBuilder, parentComposers) => + $$PlaylistTableTableFilterComposer(ComposerState($state.db, + $state.db.playlistTable, joinBuilder, parentComposers))); + return f(composer); + } +} + +class $$AudioPlayerStateTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $AudioPlayerStateTableTable> { + $$AudioPlayerStateTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get playing => $state.composableBuilder( + column: $state.table.playing, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get volume => $state.composableBuilder( + column: $state.table.volume, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get loopMode => $state.composableBuilder( + column: $state.table.loopMode, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get shuffled => $state.composableBuilder( + column: $state.table.shuffled, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +typedef $$PlaylistTableTableInsertCompanionBuilder = PlaylistTableCompanion + Function({ + Value id, + required int audioPlayerStateId, + required int index, +}); +typedef $$PlaylistTableTableUpdateCompanionBuilder = PlaylistTableCompanion + Function({ + Value id, + Value audioPlayerStateId, + Value index, +}); + +class $$PlaylistTableTableTableManager extends RootTableManager< + _$AppDatabase, + $PlaylistTableTable, + PlaylistTableData, + $$PlaylistTableTableFilterComposer, + $$PlaylistTableTableOrderingComposer, + $$PlaylistTableTableProcessedTableManager, + $$PlaylistTableTableInsertCompanionBuilder, + $$PlaylistTableTableUpdateCompanionBuilder> { + $$PlaylistTableTableTableManager(_$AppDatabase db, $PlaylistTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$PlaylistTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$PlaylistTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$PlaylistTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value audioPlayerStateId = const Value.absent(), + Value index = const Value.absent(), + }) => + PlaylistTableCompanion( + id: id, + audioPlayerStateId: audioPlayerStateId, + index: index, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required int audioPlayerStateId, + required int index, + }) => + PlaylistTableCompanion.insert( + id: id, + audioPlayerStateId: audioPlayerStateId, + index: index, + ), + )); +} + +class $$PlaylistTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $PlaylistTableTable, + PlaylistTableData, + $$PlaylistTableTableFilterComposer, + $$PlaylistTableTableOrderingComposer, + $$PlaylistTableTableProcessedTableManager, + $$PlaylistTableTableInsertCompanionBuilder, + $$PlaylistTableTableUpdateCompanionBuilder> { + $$PlaylistTableTableProcessedTableManager(super.$state); +} + +class $$PlaylistTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $PlaylistTableTable> { + $$PlaylistTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get index => $state.composableBuilder( + column: $state.table.index, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + $$AudioPlayerStateTableTableFilterComposer get audioPlayerStateId { + final $$AudioPlayerStateTableTableFilterComposer composer = + $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.audioPlayerStateId, + referencedTable: $state.db.audioPlayerStateTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, parentComposers) => + $$AudioPlayerStateTableTableFilterComposer(ComposerState( + $state.db, + $state.db.audioPlayerStateTable, + joinBuilder, + parentComposers))); + return composer; + } + + ComposableFilter playlistMediaTableRefs( + ComposableFilter Function($$PlaylistMediaTableTableFilterComposer f) f) { + final $$PlaylistMediaTableTableFilterComposer composer = $state + .composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $state.db.playlistMediaTable, + getReferencedColumn: (t) => t.playlistId, + builder: (joinBuilder, parentComposers) => + $$PlaylistMediaTableTableFilterComposer(ComposerState( + $state.db, + $state.db.playlistMediaTable, + joinBuilder, + parentComposers))); + return f(composer); + } +} + +class $$PlaylistTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $PlaylistTableTable> { + $$PlaylistTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get index => $state.composableBuilder( + column: $state.table.index, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + $$AudioPlayerStateTableTableOrderingComposer get audioPlayerStateId { + final $$AudioPlayerStateTableTableOrderingComposer composer = + $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.audioPlayerStateId, + referencedTable: $state.db.audioPlayerStateTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, parentComposers) => + $$AudioPlayerStateTableTableOrderingComposer(ComposerState( + $state.db, + $state.db.audioPlayerStateTable, + joinBuilder, + parentComposers))); + return composer; + } +} + +typedef $$PlaylistMediaTableTableInsertCompanionBuilder + = PlaylistMediaTableCompanion Function({ + Value id, + required int playlistId, + required String uri, + Value?> extras, + Value?> httpHeaders, +}); +typedef $$PlaylistMediaTableTableUpdateCompanionBuilder + = PlaylistMediaTableCompanion Function({ + Value id, + Value playlistId, + Value uri, + Value?> extras, + Value?> httpHeaders, +}); + +class $$PlaylistMediaTableTableTableManager extends RootTableManager< + _$AppDatabase, + $PlaylistMediaTableTable, + PlaylistMediaTableData, + $$PlaylistMediaTableTableFilterComposer, + $$PlaylistMediaTableTableOrderingComposer, + $$PlaylistMediaTableTableProcessedTableManager, + $$PlaylistMediaTableTableInsertCompanionBuilder, + $$PlaylistMediaTableTableUpdateCompanionBuilder> { + $$PlaylistMediaTableTableTableManager( + _$AppDatabase db, $PlaylistMediaTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$PlaylistMediaTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: $$PlaylistMediaTableTableOrderingComposer( + ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$PlaylistMediaTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value playlistId = const Value.absent(), + Value uri = const Value.absent(), + Value?> extras = const Value.absent(), + Value?> httpHeaders = const Value.absent(), + }) => + PlaylistMediaTableCompanion( + id: id, + playlistId: playlistId, + uri: uri, + extras: extras, + httpHeaders: httpHeaders, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required int playlistId, + required String uri, + Value?> extras = const Value.absent(), + Value?> httpHeaders = const Value.absent(), + }) => + PlaylistMediaTableCompanion.insert( + id: id, + playlistId: playlistId, + uri: uri, + extras: extras, + httpHeaders: httpHeaders, + ), + )); +} + +class $$PlaylistMediaTableTableProcessedTableManager + extends ProcessedTableManager< + _$AppDatabase, + $PlaylistMediaTableTable, + PlaylistMediaTableData, + $$PlaylistMediaTableTableFilterComposer, + $$PlaylistMediaTableTableOrderingComposer, + $$PlaylistMediaTableTableProcessedTableManager, + $$PlaylistMediaTableTableInsertCompanionBuilder, + $$PlaylistMediaTableTableUpdateCompanionBuilder> { + $$PlaylistMediaTableTableProcessedTableManager(super.$state); +} + +class $$PlaylistMediaTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $PlaylistMediaTableTable> { + $$PlaylistMediaTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get uri => $state.composableBuilder( + column: $state.table.uri, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters?, Map, + String> + get extras => $state.composableBuilder( + column: $state.table.extras, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters?, Map, + String> + get httpHeaders => $state.composableBuilder( + column: $state.table.httpHeaders, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + $$PlaylistTableTableFilterComposer get playlistId { + final $$PlaylistTableTableFilterComposer composer = $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.playlistId, + referencedTable: $state.db.playlistTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, parentComposers) => + $$PlaylistTableTableFilterComposer(ComposerState($state.db, + $state.db.playlistTable, joinBuilder, parentComposers))); + return composer; + } +} + +class $$PlaylistMediaTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $PlaylistMediaTableTable> { + $$PlaylistMediaTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get uri => $state.composableBuilder( + column: $state.table.uri, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get extras => $state.composableBuilder( + column: $state.table.extras, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get httpHeaders => $state.composableBuilder( + column: $state.table.httpHeaders, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + $$PlaylistTableTableOrderingComposer get playlistId { + final $$PlaylistTableTableOrderingComposer composer = + $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.playlistId, + referencedTable: $state.db.playlistTable, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, parentComposers) => + $$PlaylistTableTableOrderingComposer(ComposerState($state.db, + $state.db.playlistTable, joinBuilder, parentComposers))); + return composer; + } +} + class _$AppDatabaseManager { final _$AppDatabase _db; _$AppDatabaseManager(this._db); @@ -3761,4 +5060,10 @@ class _$AppDatabaseManager { $$SkipSegmentTableTableTableManager(_db, _db.skipSegmentTable); $$SourceMatchTableTableTableManager get sourceMatchTable => $$SourceMatchTableTableTableManager(_db, _db.sourceMatchTable); + $$AudioPlayerStateTableTableTableManager get audioPlayerStateTable => + $$AudioPlayerStateTableTableTableManager(_db, _db.audioPlayerStateTable); + $$PlaylistTableTableTableManager get playlistTable => + $$PlaylistTableTableTableManager(_db, _db.playlistTable); + $$PlaylistMediaTableTableTableManager get playlistMediaTable => + $$PlaylistMediaTableTableTableManager(_db, _db.playlistMediaTable); } diff --git a/lib/models/database/tables/audio_player_state.dart b/lib/models/database/tables/audio_player_state.dart new file mode 100644 index 00000000..45f5ffd9 --- /dev/null +++ b/lib/models/database/tables/audio_player_state.dart @@ -0,0 +1,27 @@ +part of '../database.dart'; + +class AudioPlayerStateTable extends Table { + IntColumn get id => integer().autoIncrement()(); + BoolColumn get playing => boolean()(); + RealColumn get volume => real()(); + TextColumn get loopMode => textEnum()(); + BoolColumn get shuffled => boolean()(); +} + +class PlaylistTable extends Table { + IntColumn get id => integer().autoIncrement()(); + IntColumn get audioPlayerStateId => + integer().references(AudioPlayerStateTable, #id)(); + IntColumn get index => integer()(); +} + +class PlaylistMediaTable extends Table { + IntColumn get id => integer().autoIncrement()(); + IntColumn get playlistId => integer().references(PlaylistTable, #id)(); + + TextColumn get uri => text()(); + TextColumn get extras => + text().nullable().map(const MapTypeConverter())(); + TextColumn get httpHeaders => + text().nullable().map(const MapTypeConverter())(); +} diff --git a/lib/models/database/typeconverters/map.dart b/lib/models/database/typeconverters/map.dart new file mode 100644 index 00000000..0b0ff7e0 --- /dev/null +++ b/lib/models/database/typeconverters/map.dart @@ -0,0 +1,15 @@ +part of '../database.dart'; + +class MapTypeConverter extends TypeConverter, String> { + const MapTypeConverter(); + + @override + fromSql(String fromDb) { + return json.decode(fromDb) as Map; + } + + @override + toSql(value) { + return json.encode(value); + } +} diff --git a/lib/modules/player/player_controls.dart b/lib/modules/player/player_controls.dart index ba69560c..a1a3ffcf 100644 --- a/lib/modules/player/player_controls.dart +++ b/lib/modules/player/player_controls.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; @@ -12,7 +13,6 @@ import 'package:spotube/modules/player/use_progress.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; class PlayerControls extends HookConsumerWidget { final PaletteGenerator? palette; @@ -234,38 +234,29 @@ class PlayerControls extends HookConsumerWidget { ? null : playlistNotifier.next, ), - StreamBuilder( + StreamBuilder( stream: audioPlayer.loopModeStream, builder: (context, snapshot) { - final loopMode = snapshot.data ?? PlaybackLoopMode.none; + final loopMode = snapshot.data ?? PlaylistMode.none; return IconButton( - tooltip: loopMode == PlaybackLoopMode.one + tooltip: loopMode == PlaylistMode.single ? context.l10n.loop_track - : loopMode == PlaybackLoopMode.all + : loopMode == PlaylistMode.loop ? context.l10n.repeat_playlist : null, icon: Icon( - loopMode == PlaybackLoopMode.one + loopMode == PlaylistMode.single ? SpotubeIcons.repeatOne : SpotubeIcons.repeat, ), - style: loopMode == PlaybackLoopMode.one || - loopMode == PlaybackLoopMode.all + style: loopMode == PlaylistMode.single || + loopMode == PlaylistMode.loop ? activeButtonStyle : buttonStyle, onPressed: playlist.isFetching == true ? null : () async { - audioPlayer.setLoopMode( - switch (loopMode) { - PlaybackLoopMode.all => - PlaybackLoopMode.one, - PlaybackLoopMode.one => - PlaybackLoopMode.none, - PlaybackLoopMode.none => - PlaybackLoopMode.all, - }, - ); + await audioPlayer.setLoopMode(loopMode); }, ); }), diff --git a/lib/modules/player/use_progress.dart b/lib/modules/player/use_progress.dart index 15a979af..eaea638e 100644 --- a/lib/modules/player/use_progress.dart +++ b/lib/modules/player/use_progress.dart @@ -1,4 +1,3 @@ -import 'package:async/async.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -19,26 +18,13 @@ import 'package:spotube/services/audio_player/audio_player.dart'; final sliderValue = position.value.inSeconds; useEffect(() { - final durationOperation = - CancelableOperation.fromFuture(audioPlayer.duration); - durationOperation.then((value) { - if (value != null) { - duration.value = value; - } - }); + duration.value = audioPlayer.duration; final durationSubscription = audioPlayer.durationStream.listen((event) { duration.value = event; }); - final positionOperation = - CancelableOperation.fromFuture(audioPlayer.position); - - positionOperation.then((value) { - if (value != null) { - position.value = value; - } - }); + position.value = audioPlayer.position; var lastPosition = position.value; @@ -54,9 +40,7 @@ import 'package:spotube/services/audio_player/audio_player.dart'; }); return () { - positionOperation.cancel(); positionSubscription.cancel(); - durationOperation.cancel(); durationSubscription.cancel(); }; }, []); diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index eb2c48c5..d27b7867 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -16,7 +16,7 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:media_kit/media_kit.dart' hide Track; import 'package:spotube/utils/service_utils.dart'; class RemotePlayerQueue extends ConsumerWidget { @@ -244,18 +244,18 @@ class ConnectControlPage extends HookConsumerWidget { : connectNotifier.next, ), IconButton( - tooltip: loopMode == PlaybackLoopMode.one + tooltip: loopMode == PlaylistMode.single ? context.l10n.loop_track - : loopMode == PlaybackLoopMode.all + : loopMode == PlaylistMode.loop ? context.l10n.repeat_playlist : null, icon: Icon( - loopMode == PlaybackLoopMode.one + loopMode == PlaylistMode.single ? SpotubeIcons.repeatOne : SpotubeIcons.repeat, ), - style: loopMode == PlaybackLoopMode.one || - loopMode == PlaybackLoopMode.all + style: loopMode == PlaylistMode.single || + loopMode == PlaylistMode.loop ? activeButtonStyle : buttonStyle, onPressed: playlist.activeTrack == null @@ -263,12 +263,11 @@ class ConnectControlPage extends HookConsumerWidget { : () async { connectNotifier.setLoopMode( switch (loopMode) { - PlaybackLoopMode.all => - PlaybackLoopMode.one, - PlaybackLoopMode.one => - PlaybackLoopMode.none, - PlaybackLoopMode.none => - PlaybackLoopMode.all, + PlaylistMode.loop => + PlaylistMode.single, + PlaylistMode.single => + PlaylistMode.none, + PlaylistMode.none => PlaylistMode.loop, }, ); }, diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart new file mode 100644 index 00000000..747c78e6 --- /dev/null +++ b/lib/provider/audio_player/audio_player.dart @@ -0,0 +1,225 @@ +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:media_kit/media_kit.dart' hide Track; +import 'package:spotify/spotify.dart' hide Playlist; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/audio_player/state.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; + +class AudioPlayerNotifier extends Notifier { + Future _syncSavedState() async { + final database = ref.read(databaseProvider); + + var playerState = + await database.select(database.audioPlayerStateTable).getSingleOrNull(); + + if (playerState == null) { + await database.into(database.audioPlayerStateTable).insert( + AudioPlayerStateTableCompanion.insert( + playing: audioPlayer.isPlaying, + volume: audioPlayer.volume, + loopMode: audioPlayer.loopMode, + shuffled: audioPlayer.isShuffled, + id: const Value(0), + ), + ); + + playerState = + await database.select(database.audioPlayerStateTable).getSingle(); + } else { + await audioPlayer.setVolume(playerState.volume); + await audioPlayer.setLoopMode(playerState.loopMode); + await audioPlayer.setShuffle(playerState.shuffled); + } + + var playlist = + await database.select(database.playlistTable).getSingleOrNull(); + var medias = await database.select(database.playlistMediaTable).get(); + + if (playlist == null) { + await database.into(database.playlistTable).insert( + PlaylistTableCompanion.insert( + audioPlayerStateId: 0, + index: audioPlayer.playlist.index, + id: const Value(0), + ), + ); + + playlist = await database.select(database.playlistTable).getSingle(); + } + + if (medias.isEmpty && audioPlayer.playlist.medias.isNotEmpty) { + await database.batch((batch) { + batch.insertAll( + database.playlistMediaTable, + [ + for (final media in audioPlayer.playlist.medias) + PlaylistMediaTableCompanion.insert( + playlistId: playlist!.id, + uri: media.uri, + extras: Value(media.extras), + httpHeaders: Value(media.httpHeaders), + ), + ], + ); + }); + } else { + await audioPlayer.openPlaylist( + medias + .map((media) => Media( + media.uri, + extras: media.extras, + httpHeaders: media.httpHeaders, + )) + .toList(), + initialIndex: playlist.index, + ); + } + } + + Future _updatePlayerState( + AudioPlayerStateTableCompanion companion, + ) async { + final database = ref.read(databaseProvider); + + await (database.update(database.audioPlayerStateTable) + ..where((tb) => tb.id.equals(0))) + .write(companion); + } + + Future _updatePlaylist( + Playlist playlist, + ) async { + final database = ref.read(databaseProvider); + + await database.batch((batch) { + batch.update( + database.playlistTable, + PlaylistTableCompanion(index: Value(playlist.index)), + where: (tb) => tb.id.equals(0), + ); + + batch.deleteAll(database.playlistMediaTable); + + if (playlist.medias.isEmpty) return; + batch.insertAll( + database.playlistMediaTable, + [ + for (final media in playlist.medias) + PlaylistMediaTableCompanion.insert( + playlistId: 0, + uri: media.uri, + extras: Value(media.extras), + httpHeaders: Value(media.httpHeaders), + ), + ], + ); + }); + } + + @override + build() { + final subscriptions = [ + audioPlayer.playingStream.listen((playing) async { + state = state.copyWith(playing: playing); + + await _updatePlayerState( + AudioPlayerStateTableCompanion( + playing: Value(playing), + ), + ); + }), + audioPlayer.volumeStream.listen((volume) async { + state = state.copyWith(volume: volume); + + await _updatePlayerState( + AudioPlayerStateTableCompanion( + volume: Value(volume), + ), + ); + }), + audioPlayer.loopModeStream.listen((loopMode) async { + state = state.copyWith(loopMode: loopMode); + + await _updatePlayerState( + AudioPlayerStateTableCompanion( + loopMode: Value(loopMode), + ), + ); + }), + audioPlayer.shuffledStream.listen((shuffled) async { + state = state.copyWith(shuffled: shuffled); + + await _updatePlayerState( + AudioPlayerStateTableCompanion( + shuffled: Value(shuffled), + ), + ); + }), + audioPlayer.playlistStream.listen((playlist) async { + state = state.copyWith(playlist: playlist); + + await _updatePlaylist(playlist); + }), + ]; + + _syncSavedState(); + + ref.onDispose(() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + }); + + return AudioPlayerState( + loopMode: audioPlayer.loopMode, + playing: audioPlayer.isPlaying, + playlist: audioPlayer.playlist, + shuffled: audioPlayer.isShuffled, + volume: audioPlayer.volume, + ); + } + + // Tracks related methods + + Future addTrack(Track track) async { + await audioPlayer.addTrack(SpotubeMedia(track)); + } + + Future addTracks(Iterable tracks) async { + for (final track in tracks) { + await addTrack(track); + } + } + + Future removeTrack(Track track) async { + final index = state.tracks.indexWhere((element) => element == track); + + if (index == -1) return; + + await audioPlayer.removeTrack(index); + } + + Future removeTracks(Iterable tracks) async { + for (final track in tracks) { + await removeTrack(track); + } + } + + Future load( + List track, { + required int initialIndex, + bool autoPlay = false, + }) async { + await audioPlayer.openPlaylist( + track.map((t) => SpotubeMedia(t)).toList(), + initialIndex: initialIndex, + autoPlay: autoPlay, + ); + } +} + +final audioPlayerProvider = NotifierProvider( + () => AudioPlayerNotifier(), +); \ No newline at end of file diff --git a/lib/provider/audio_player/state.dart b/lib/provider/audio_player/state.dart new file mode 100644 index 00000000..3c874011 --- /dev/null +++ b/lib/provider/audio_player/state.dart @@ -0,0 +1,42 @@ +import 'package:media_kit/media_kit.dart' hide Track; +import 'package:spotify/spotify.dart' hide Playlist; +import 'package:spotube/services/audio_player/audio_player.dart'; + +class AudioPlayerState { + final bool playing; + final double volume; + final PlaylistMode loopMode; + final bool shuffled; + final Playlist playlist; + + final List tracks; + + AudioPlayerState({ + required this.playing, + required this.volume, + required this.loopMode, + required this.shuffled, + required this.playlist, + List? tracks, + }) : tracks = tracks ?? + playlist.medias + .map((media) => SpotubeMedia.fromMedia(media).track) + .toList(); + + AudioPlayerState copyWith({ + bool? playing, + double? volume, + PlaylistMode? loopMode, + bool? shuffled, + Playlist? playlist, + }) { + return AudioPlayerState( + playing: playing ?? this.playing, + volume: volume ?? this.volume, + loopMode: loopMode ?? this.loopMode, + shuffled: shuffled ?? this.shuffled, + playlist: playlist ?? this.playlist, + tracks: playlist == null ? tracks : null, + ); + } +} diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart index feb9fbd2..c6014445 100644 --- a/lib/provider/connect/connect.dart +++ b/lib/provider/connect/connect.dart @@ -1,13 +1,13 @@ import 'dart:convert'; +import 'package:media_kit/media_kit.dart' hide Track; import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; +import 'package:spotify/spotify.dart' hide Playlist; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/status.dart' as status; @@ -27,8 +27,8 @@ final shuffleProvider = StateProvider( (ref) => false, ); -final loopModeProvider = StateProvider( - (ref) => PlaybackLoopMode.none, +final loopModeProvider = StateProvider( + (ref) => PlaylistMode.none, ); final queueProvider = StateProvider( @@ -158,7 +158,7 @@ class ConnectNotifier extends AsyncNotifier { emit(WebSocketShuffleEvent(value)); } - Future setLoopMode(PlaybackLoopMode value) async { + Future setLoopMode(PlaylistMode value) async { emit(WebSocketLoopEvent(value)); } diff --git a/lib/provider/tray_manager/tray_menu.dart b/lib/provider/tray_manager/tray_menu.dart index cb793707..35aca4f5 100644 --- a/lib/provider/tray_manager/tray_menu.dart +++ b/lib/provider/tray_manager/tray_menu.dart @@ -3,11 +3,11 @@ import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:media_kit/media_kit.dart' hide Track; import 'package:tray_manager/tray_manager.dart'; import 'package:window_manager/window_manager.dart'; -final audioPlayerLoopMode = StreamProvider((ref) { +final audioPlayerLoopMode = StreamProvider((ref) { return audioPlayer.loopModeStream; }); @@ -23,7 +23,7 @@ final trayMenuProvider = Provider((ref) { final isPlaybackPlaying = ref.watch(proxyPlaylistProvider.select((s) => s.activeTrack != null)); final isLoopOne = - ref.watch(audioPlayerLoopMode).asData?.value == PlaybackLoopMode.one; + ref.watch(audioPlayerLoopMode).asData?.value == PlaylistMode.single; final isShuffled = ref.watch(audioPlayerShuffleMode).asData?.value ?? false; final isPlaying = ref.watch(audioPlayerPlaying).asData?.value ?? false; @@ -75,7 +75,7 @@ final trayMenuProvider = Provider((ref) { checked: isLoopOne, onClick: (menuItem) { audioPlayer.setLoopMode( - isLoopOne ? PlaybackLoopMode.none : PlaybackLoopMode.one, + isLoopOne ? PlaylistMode.none : PlaylistMode.single, ); }, ), diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index df23039c..713d518b 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,16 +1,16 @@ import 'dart:io'; +import 'package:media_kit/media_kit.dart' hide Track; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:flutter/foundation.dart'; -import 'package:spotify/spotify.dart'; +import 'package:spotify/spotify.dart' hide Playlist; import 'package:spotube/models/local_track.dart'; import 'package:spotube/services/audio_player/custom_player.dart'; import 'dart:async'; import 'package:media_kit/media_kit.dart' as mk; -import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -66,15 +66,19 @@ abstract class AudioPlayerInterface { bool get mkSupportedPlatform => _mkSupportedPlatform; - Future get duration async { + Duration get duration { return _mkPlayer.state.duration; } - Future get position async { + Playlist get playlist { + return _mkPlayer.state.playlist; + } + + Duration get position { return _mkPlayer.state.position; } - Future get bufferedPosition async { + Duration get bufferedPosition { return _mkPlayer.state.buffer; } @@ -111,8 +115,8 @@ abstract class AudioPlayerInterface { return _mkPlayer.shuffled; } - PlaybackLoopMode get loopMode { - return PlaybackLoopMode.fromPlaylistMode(_mkPlayer.state.playlistMode); + PlaylistMode get loopMode { + return _mkPlayer.state.playlistMode; } /// Returns the current volume of the player, between 0 and 1 diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart index 58868aed..82c8c906 100644 --- a/lib/services/audio_player/audio_player_impl.dart +++ b/lib/services/audio_player/audio_player_impl.dart @@ -65,7 +65,7 @@ class SpotubeAudioPlayer extends AudioPlayerInterface } String? get nextSource { - if (loopMode == PlaybackLoopMode.all && + if (loopMode == PlaylistMode.loop && _mkPlayer.state.playlist.index == _mkPlayer.state.playlist.medias.length - 1) { return sources.first; @@ -77,8 +77,7 @@ class SpotubeAudioPlayer extends AudioPlayerInterface } String? get previousSource { - if (loopMode == PlaybackLoopMode.all && - _mkPlayer.state.playlist.index == 0) { + if (loopMode == PlaylistMode.loop && _mkPlayer.state.playlist.index == 0) { return sources.last; } @@ -125,8 +124,8 @@ class SpotubeAudioPlayer extends AudioPlayerInterface await _mkPlayer.setShuffle(shuffle); } - Future setLoopMode(PlaybackLoopMode loop) async { - await _mkPlayer.setPlaylistMode(loop.toPlaylistMode()); + Future setLoopMode(PlaylistMode loop) async { + await _mkPlayer.setPlaylistMode(loop); } Future setAudioNormalization(bool normalize) async { diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index f6fe0630..03ce0d5d 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -71,12 +71,12 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { // } } - Stream get loopModeStream { + Stream get loopModeStream { // if (mkSupportedPlatform) { - return _mkPlayer.stream.playlistMode.map(PlaybackLoopMode.fromPlaylistMode); + return _mkPlayer.stream.playlistMode; // } else { // return _justAudio!.loopModeStream - // .map(PlaybackLoopMode.fromLoopMode) + // .map(PlaylistMode.fromLoopMode) // ; // } } diff --git a/lib/services/audio_player/loop_mode.dart b/lib/services/audio_player/loop_mode.dart deleted file mode 100644 index 78da43ba..00000000 --- a/lib/services/audio_player/loop_mode.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:audio_service/audio_service.dart'; -import 'package:media_kit/media_kit.dart'; - -/// An unified loop mode for both [LoopMode] and [PlaylistMode] -enum PlaybackLoopMode { - all, - one, - none; - - // static PlaybackLoopMode fromLoopMode(LoopMode loopMode) { - // switch (loopMode) { - // case LoopMode.all: - // return PlaybackLoopMode.all; - // case LoopMode.one: - // return PlaybackLoopMode.one; - // case LoopMode.off: - // return PlaybackLoopMode.none; - // } - // } - - // LoopMode toLoopMode() { - // switch (this) { - // case PlaybackLoopMode.all: - // return LoopMode.all; - // case PlaybackLoopMode.one: - // return LoopMode.one; - // case PlaybackLoopMode.none: - // return LoopMode.off; - // } - // } - - static PlaybackLoopMode fromPlaylistMode(PlaylistMode mode) { - switch (mode) { - case PlaylistMode.single: - return PlaybackLoopMode.one; - case PlaylistMode.loop: - return PlaybackLoopMode.all; - case PlaylistMode.none: - return PlaybackLoopMode.none; - } - } - - PlaylistMode toPlaylistMode() { - switch (this) { - case PlaybackLoopMode.all: - return PlaylistMode.loop; - case PlaybackLoopMode.one: - return PlaylistMode.single; - case PlaybackLoopMode.none: - return PlaylistMode.none; - } - } - - static PlaybackLoopMode fromAudioServiceRepeatMode( - AudioServiceRepeatMode mode) { - switch (mode) { - case AudioServiceRepeatMode.all: - case AudioServiceRepeatMode.group: - return PlaybackLoopMode.all; - case AudioServiceRepeatMode.one: - return PlaybackLoopMode.one; - case AudioServiceRepeatMode.none: - return PlaybackLoopMode.none; - } - } - - AudioServiceRepeatMode toAudioServiceRepeatMode() { - switch (this) { - case PlaybackLoopMode.all: - return AudioServiceRepeatMode.all; - case PlaybackLoopMode.one: - return AudioServiceRepeatMode.one; - case PlaybackLoopMode.none: - return AudioServiceRepeatMode.none; - } - } - - static PlaybackLoopMode fromString(String? value) { - switch (value) { - case 'all': - return PlaybackLoopMode.all; - case 'one': - return PlaybackLoopMode.one; - case 'none': - return PlaybackLoopMode.none; - default: - return PlaybackLoopMode.none; - } - } -} diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 62cc8552..3dbae18f 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -5,7 +5,7 @@ import 'package:audio_session/audio_session.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:media_kit/media_kit.dart' hide Track; class MobileAudioService extends BaseAudioHandler { AudioSession? session; @@ -91,9 +91,13 @@ class MobileAudioService extends BaseAudioHandler { @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { super.setRepeatMode(repeatMode); - audioPlayer.setLoopMode( - PlaybackLoopMode.fromAudioServiceRepeatMode(repeatMode), - ); + audioPlayer.setLoopMode(switch (repeatMode) { + AudioServiceRepeatMode.all || + AudioServiceRepeatMode.group => + PlaylistMode.loop, + AudioServiceRepeatMode.one => PlaylistMode.single, + _ => PlaylistMode.none, + }); } @override @@ -120,7 +124,6 @@ class MobileAudioService extends BaseAudioHandler { } Future _transformEvent() async { - final position = (await audioPlayer.position) ?? Duration.zero; return PlaybackState( controls: [ MediaControl.skipToPrevious, @@ -133,12 +136,16 @@ class MobileAudioService extends BaseAudioHandler { }, androidCompactActionIndices: const [0, 1, 2], playing: audioPlayer.isPlaying, - updatePosition: position, - bufferedPosition: await audioPlayer.bufferedPosition ?? Duration.zero, + updatePosition: audioPlayer.position, + bufferedPosition: audioPlayer.bufferedPosition, shuffleMode: audioPlayer.isShuffled == true ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, - repeatMode: (audioPlayer.loopMode).toAudioServiceRepeatMode(), + repeatMode: switch (audioPlayer.loopMode) { + PlaylistMode.loop => AudioServiceRepeatMode.all, + PlaylistMode.single => AudioServiceRepeatMode.one, + _ => AudioServiceRepeatMode.none, + }, processingState: playlist.isFetching == true ? AudioProcessingState.loading : AudioProcessingState.ready, From a83dd64476486bdad88b0c0f2afb56e2b90e7f0f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 24 Jun 2024 20:52:40 +0600 Subject: [PATCH 147/261] refactor: replace all instances of proxy playlist --- lib/collections/intents.dart | 8 +- lib/components/track_tile/track_options.dart | 14 +- lib/components/track_tile/track_tile.dart | 11 +- .../sections/body/track_view_body.dart | 6 +- .../sections/body/track_view_options.dart | 4 +- .../sections/header/header_actions.dart | 8 +- .../sections/header/header_buttons.dart | 6 +- .../configurators/use_endless_playback.dart | 16 +- lib/main.dart | 4 +- lib/models/connect/connect.dart | 2 +- lib/models/connect/ws_event.dart | 6 +- lib/models/database/database.g.dart | 128 ++++++----- .../database/tables/audio_player_state.dart | 2 +- lib/modules/album/album_card.dart | 8 +- lib/modules/player/player.dart | 21 +- lib/modules/player/player_actions.dart | 8 +- lib/modules/player/player_controls.dart | 24 +- lib/modules/player/player_overlay.dart | 16 +- lib/modules/player/player_queue.dart | 15 +- lib/modules/player/player_track_details.dart | 4 +- lib/modules/player/sibling_tracks_sheet.dart | 17 +- lib/modules/playlist/playlist_card.dart | 10 +- lib/modules/root/bottom_player.dart | 6 +- lib/pages/artist/section/header.dart | 2 +- lib/pages/artist/section/top_tracks.dart | 6 +- lib/pages/library/local_folder.dart | 8 +- .../playlist_generate_result.dart | 13 +- lib/pages/lyrics/lyrics.dart | 6 +- lib/pages/lyrics/mini_lyrics.dart | 11 +- lib/pages/lyrics/plain_lyrics.dart | 4 +- lib/pages/lyrics/synced_lyrics.dart | 6 +- lib/pages/root/root_app.dart | 8 +- lib/pages/search/sections/tracks.dart | 6 +- lib/pages/track/track.dart | 6 +- lib/provider/audio_player/audio_player.dart | 137 +++++++++-- .../audio_player_streams.dart} | 87 ++++--- lib/provider/audio_player/state.dart | 66 +++++- lib/provider/connect/connect.dart | 13 +- lib/provider/discord_provider.dart | 4 +- .../proxy_playlist/proxy_playlist.dart | 101 -------- .../proxy_playlist_provider.dart | 215 ------------------ lib/provider/server/active_sourced_track.dart | 4 +- lib/provider/server/routes/connect.dart | 28 +-- lib/provider/server/routes/playback.dart | 6 +- lib/provider/server/sourced_track.dart | 7 +- .../skip_segments.dart | 0 lib/provider/tray_manager/tray_menu.dart | 10 +- .../user_preferences_provider.dart | 5 +- .../audio_services/audio_services.dart | 4 +- .../audio_services/mobile_audio_service.dart | 20 +- .../audio_services/windows_audio_service.dart | 12 +- 51 files changed, 515 insertions(+), 624 deletions(-) rename lib/provider/{proxy_playlist/player_listeners.dart => audio_player/audio_player_streams.dart} (51%) delete mode 100644 lib/provider/proxy_playlist/proxy_playlist.dart delete mode 100644 lib/provider/proxy_playlist/proxy_playlist_provider.dart rename lib/provider/{proxy_playlist => skip_segments}/skip_segments.dart (100%) diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 6d6e643e..1a44a846 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -11,7 +11,7 @@ import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; @@ -96,8 +96,8 @@ class SeekIntent extends Intent { class SeekAction extends Action { @override invoke(intent) async { - final playlist = intent.ref.read(proxyPlaylistProvider); - if (playlist.isFetching) { + final playlist = intent.ref.read(audioPlayerProvider.notifier); + if (playlist.isFetching()) { DirectionalFocusAction().invoke( DirectionalFocusIntent( intent.forward ? TraversalDirection.right : TraversalDirection.left, @@ -105,7 +105,7 @@ class SeekAction extends Action { ); return null; } - final position = (await audioPlayer.position ?? Duration.zero).inSeconds; + final position = audioPlayer.position.inSeconds; await audioPlayer.seek( Duration( seconds: intent.forward ? position + 5 : position - 5, diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index d54a0c15..c6cfdd35 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -24,7 +24,7 @@ import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; @@ -96,8 +96,8 @@ class TrackOptions extends HookConsumerWidget { WidgetRef ref, Track track, ) async { - final playback = ref.read(proxyPlaylistProvider.notifier); - final playlist = ref.read(proxyPlaylistProvider); + final playback = ref.read(audioPlayerProvider.notifier); + final playlist = ref.read(audioPlayerProvider); final spotify = ref.read(spotifyProvider); final query = "${track.name} Radio"; final pages = @@ -160,8 +160,8 @@ class TrackOptions extends HookConsumerWidget { final router = GoRouter.of(context); final ThemeData(:colorScheme) = Theme.of(context); - final playlist = ref.watch(proxyPlaylistProvider); - final playback = ref.watch(proxyPlaylistProvider.notifier); + final playlist = ref.watch(audioPlayerProvider); + final playback = ref.watch(audioPlayerProvider.notifier); final auth = ref.watch(authenticationProvider); ref.watch(downloadManagerProvider); final downloadManager = ref.watch(downloadManagerProvider.notifier); @@ -364,7 +364,7 @@ class TrackOptions extends HookConsumerWidget { : context.l10n.save_as_favorite, ), ), - if (auth != null && !isLocalTrack) ...[ + if (auth.asData?.value != null && !isLocalTrack) ...[ PopSheetEntry( value: TrackOptionValue.startRadio, leading: const Icon(SpotubeIcons.radio), @@ -376,7 +376,7 @@ class TrackOptions extends HookConsumerWidget { title: Text(context.l10n.add_to_playlist), ), ], - if (userPlaylist && auth != null && !isLocalTrack) + if (userPlaylist && auth.asData?.value != null && !isLocalTrack) PopSheetEntry( value: TrackOptionValue.removeFromPlaylist, leading: const Icon(SpotubeIcons.removeFilled), diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index e2e7e293..cdc18d9b 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -17,8 +17,9 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; class TrackTile extends HookConsumerWidget { /// [index] will not be shown if null @@ -30,7 +31,7 @@ class TrackTile extends HookConsumerWidget { final VoidCallback? onLongPress; final bool userPlaylist; final String? playlistId; - final ProxyPlaylist playlist; + final AudioPlayerState playlist; final List? leadingActions; @@ -160,7 +161,11 @@ class TrackTile extends HookConsumerWidget { child: Skeleton.ignore( child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), - child: (isPlaying && playlist.isFetching) || + child: (isPlaying && + ref + .watch(audioPlayerProvider + .notifier) + .isFetching()) || isLoading.value ? const SizedBox( width: 26, diff --git a/lib/components/tracks_view/sections/body/track_view_body.dart b/lib/components/tracks_view/sections/body/track_view_body.dart index 0c3cca4e..a6089cc3 100644 --- a/lib/components/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/tracks_view/sections/body/track_view_body.dart @@ -18,7 +18,7 @@ import 'package:spotube/components/tracks_view/track_view_provider.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @@ -27,8 +27,8 @@ class TrackViewBodySection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(proxyPlaylistProvider); - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final props = InheritedTrackView.of(context); final trackViewState = ref.watch(trackViewProvider(props.tracks)); diff --git a/lib/components/tracks_view/sections/body/track_view_options.dart b/lib/components/tracks_view/sections/body/track_view_options.dart index 1accba34..98ddca25 100644 --- a/lib/components/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/tracks_view/sections/body/track_view_options.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; class TrackViewBodyOptions extends HookConsumerWidget { @@ -24,7 +24,7 @@ class TrackViewBodyOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final audioSource = ref.watch(userPreferencesProvider.select((s) => s.audioSource)); diff --git a/lib/components/tracks_view/sections/header/header_actions.dart b/lib/components/tracks_view/sections/header/header_actions.dart index f20cd553..6769ed52 100644 --- a/lib/components/tracks_view/sections/header/header_actions.dart +++ b/lib/components/tracks_view/sections/header/header_actions.dart @@ -11,7 +11,7 @@ import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; class TrackViewHeaderActions extends HookConsumerWidget { const TrackViewHeaderActions({super.key}); @@ -20,8 +20,8 @@ class TrackViewHeaderActions extends HookConsumerWidget { Widget build(BuildContext context, ref) { final props = InheritedTrackView.of(context); - final playlist = ref.watch(proxyPlaylistProvider); - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); @@ -73,7 +73,7 @@ class TrackViewHeaderActions extends HookConsumerWidget { } }, ), - if (props.onHeart != null && auth != null) + if (props.onHeart != null && auth.asData?.value != null) HeartButton( isLiked: props.isLiked, icon: isUserPlaylist ? SpotubeIcons.trash : null, diff --git a/lib/components/tracks_view/sections/header/header_buttons.dart b/lib/components/tracks_view/sections/header/header_buttons.dart index aa660f01..aabca20f 100644 --- a/lib/components/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/tracks_view/sections/header/header_buttons.dart @@ -13,7 +13,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; class TrackViewHeaderButtons extends HookConsumerWidget { @@ -28,8 +28,8 @@ class TrackViewHeaderButtons extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final props = InheritedTrackView.of(context); - final playlist = ref.watch(proxyPlaylistProvider); - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final historyNotifier = ref.watch(playbackHistoryProvider.notifier); final isActive = playlist.collections.contains(props.collectionId); diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart index 9b90b23d..e2fb1e6e 100644 --- a/lib/hooks/configurators/use_endless_playback.dart +++ b/lib/hooks/configurators/use_endless_playback.dart @@ -3,15 +3,15 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; void useEndlessPlayback(WidgetRef ref) { final auth = ref.watch(authenticationProvider); - final playback = ref.watch(proxyPlaylistProvider.notifier); - final playlist = ref.watch(proxyPlaylistProvider); + final playback = ref.watch(audioPlayerProvider.notifier); + final playlist = ref.watch(audioPlayerProvider.select((s) => s.playlist)); final spotify = ref.watch(spotifyProvider); final endlessPlayback = ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback)); @@ -22,7 +22,7 @@ void useEndlessPlayback(WidgetRef ref) { void listener(int index) async { try { - final playlist = ref.read(proxyPlaylistProvider); + final playlist = ref.read(audioPlayerProvider); if (index != playlist.tracks.length - 1) return; final track = playlist.tracks.last; @@ -56,7 +56,7 @@ void useEndlessPlayback(WidgetRef ref) { await playback.addTracks( tracks.toList() ..removeWhere((e) { - final playlist = ref.read(proxyPlaylistProvider); + final playlist = ref.read(audioPlayerProvider); final isDuplicate = playlist.tracks.any((t) => t.id == e.id); return e.id == track.id || isDuplicate; }), @@ -69,9 +69,9 @@ void useEndlessPlayback(WidgetRef ref) { // Sometimes user can change settings for which the currentIndexChanged // might not be called. So we need to check if the current track is the // last track and if it is then we need to call the listener manually. - if (playlist.active == playlist.tracks.length - 1 && + if (playlist.index == playlist.medias.length - 1 && audioPlayer.isPlaying) { - listener(playlist.active!); + listener(playlist.index); } final subscription = @@ -82,7 +82,7 @@ void useEndlessPlayback(WidgetRef ref) { [ spotify, playback, - playlist.tracks, + playlist.medias, endlessPlayback, auth, ], diff --git a/lib/main.dart b/lib/main.dart index 09db495c..9b92a21d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,6 +18,7 @@ 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/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/server/bonsoir.dart'; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/provider/tray_manager/tray_manager.dart'; @@ -113,9 +114,10 @@ class Spotube extends HookConsumerWidget { ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); final router = ref.watch(routerProvider); - ref.listen(serverProvider, (_, __) {}); + ref.listen(audioPlayerStreamListenersProvider, (_, __) {}); ref.listen(bonsoirProvider, (_, __) {}); ref.listen(connectClientsProvider, (_, __) {}); + ref.listen(serverProvider, (_, __) {}); ref.listen(trayManagerProvider, (_, __) {}); useDisableBatteryOptimizations(); diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart index 0a06be32..a70520ad 100644 --- a/lib/models/connect/connect.dart +++ b/lib/models/connect/connect.dart @@ -6,7 +6,7 @@ import 'dart:convert'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:media_kit/media_kit.dart' hide Track; import 'package:spotify/spotify.dart' hide Playlist; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/provider/audio_player/state.dart'; part 'connect.freezed.dart'; part 'connect.g.dart'; diff --git a/lib/models/connect/ws_event.dart b/lib/models/connect/ws_event.dart index c3c29e76..d1047646 100644 --- a/lib/models/connect/ws_event.dart +++ b/lib/models/connect/ws_event.dart @@ -325,12 +325,12 @@ class WebSocketErrorEvent extends WebSocketEvent { WebSocketErrorEvent(String data) : super(WsEvent.error, data); } -class WebSocketQueueEvent extends WebSocketEvent { - WebSocketQueueEvent(ProxyPlaylist data) : super(WsEvent.queue, data); +class WebSocketQueueEvent extends WebSocketEvent { + WebSocketQueueEvent(AudioPlayerState data) : super(WsEvent.queue, data); factory WebSocketQueueEvent.fromJson(Map json) => WebSocketQueueEvent( - ProxyPlaylist.fromJsonRaw(json), + AudioPlayerState.fromJson(json), ); } diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index ca9d6d97..37cc930c 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -2599,11 +2599,6 @@ class $AudioPlayerStateTableTable extends AudioPlayerStateTable requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("playing" IN (0, 1))')); - static const VerificationMeta _volumeMeta = const VerificationMeta('volume'); - @override - late final GeneratedColumn volume = GeneratedColumn( - 'volume', aliasedName, false, - type: DriftSqlType.double, requiredDuringInsert: true); static const VerificationMeta _loopModeMeta = const VerificationMeta('loopMode'); @override @@ -2621,9 +2616,17 @@ class $AudioPlayerStateTableTable extends AudioPlayerStateTable requiredDuringInsert: true, defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("shuffled" IN (0, 1))')); + static const VerificationMeta _collectionsMeta = + const VerificationMeta('collections'); + @override + late final GeneratedColumnWithTypeConverter, String> + collections = GeneratedColumn('collections', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter>( + $AudioPlayerStateTableTable.$convertercollections); @override List get $columns => - [id, playing, volume, loopMode, shuffled]; + [id, playing, loopMode, shuffled, collections]; @override String get aliasedName => _alias ?? actualTableName; @override @@ -2644,12 +2647,6 @@ class $AudioPlayerStateTableTable extends AudioPlayerStateTable } else if (isInserting) { context.missing(_playingMeta); } - if (data.containsKey('volume')) { - context.handle(_volumeMeta, - volume.isAcceptableOrUnknown(data['volume']!, _volumeMeta)); - } else if (isInserting) { - context.missing(_volumeMeta); - } context.handle(_loopModeMeta, const VerificationResult.success()); if (data.containsKey('shuffled')) { context.handle(_shuffledMeta, @@ -2657,6 +2654,7 @@ class $AudioPlayerStateTableTable extends AudioPlayerStateTable } else if (isInserting) { context.missing(_shuffledMeta); } + context.handle(_collectionsMeta, const VerificationResult.success()); return context; } @@ -2671,13 +2669,14 @@ class $AudioPlayerStateTableTable extends AudioPlayerStateTable .read(DriftSqlType.int, data['${effectivePrefix}id'])!, playing: attachedDatabase.typeMapping .read(DriftSqlType.bool, data['${effectivePrefix}playing'])!, - volume: attachedDatabase.typeMapping - .read(DriftSqlType.double, data['${effectivePrefix}volume'])!, loopMode: $AudioPlayerStateTableTable.$converterloopMode.fromSql( attachedDatabase.typeMapping .read(DriftSqlType.string, data['${effectivePrefix}loop_mode'])!), shuffled: attachedDatabase.typeMapping .read(DriftSqlType.bool, data['${effectivePrefix}shuffled'])!, + collections: $AudioPlayerStateTableTable.$convertercollections.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}collections'])!), ); } @@ -2688,32 +2687,37 @@ class $AudioPlayerStateTableTable extends AudioPlayerStateTable static JsonTypeConverter2 $converterloopMode = const EnumNameConverter(PlaylistMode.values); + static TypeConverter, String> $convertercollections = + const StringListConverter(); } class AudioPlayerStateTableData extends DataClass implements Insertable { final int id; final bool playing; - final double volume; final PlaylistMode loopMode; final bool shuffled; + final List collections; const AudioPlayerStateTableData( {required this.id, required this.playing, - required this.volume, required this.loopMode, - required this.shuffled}); + required this.shuffled, + required this.collections}); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); map['playing'] = Variable(playing); - map['volume'] = Variable(volume); { map['loop_mode'] = Variable( $AudioPlayerStateTableTable.$converterloopMode.toSql(loopMode)); } map['shuffled'] = Variable(shuffled); + { + map['collections'] = Variable( + $AudioPlayerStateTableTable.$convertercollections.toSql(collections)); + } return map; } @@ -2721,9 +2725,9 @@ class AudioPlayerStateTableData extends DataClass return AudioPlayerStateTableCompanion( id: Value(id), playing: Value(playing), - volume: Value(volume), loopMode: Value(loopMode), shuffled: Value(shuffled), + collections: Value(collections), ); } @@ -2733,10 +2737,10 @@ class AudioPlayerStateTableData extends DataClass return AudioPlayerStateTableData( id: serializer.fromJson(json['id']), playing: serializer.fromJson(json['playing']), - volume: serializer.fromJson(json['volume']), loopMode: $AudioPlayerStateTableTable.$converterloopMode .fromJson(serializer.fromJson(json['loopMode'])), shuffled: serializer.fromJson(json['shuffled']), + collections: serializer.fromJson>(json['collections']), ); } @override @@ -2745,103 +2749,103 @@ class AudioPlayerStateTableData extends DataClass return { 'id': serializer.toJson(id), 'playing': serializer.toJson(playing), - 'volume': serializer.toJson(volume), 'loopMode': serializer.toJson( $AudioPlayerStateTableTable.$converterloopMode.toJson(loopMode)), 'shuffled': serializer.toJson(shuffled), + 'collections': serializer.toJson>(collections), }; } AudioPlayerStateTableData copyWith( {int? id, bool? playing, - double? volume, PlaylistMode? loopMode, - bool? shuffled}) => + bool? shuffled, + List? collections}) => AudioPlayerStateTableData( id: id ?? this.id, playing: playing ?? this.playing, - volume: volume ?? this.volume, loopMode: loopMode ?? this.loopMode, shuffled: shuffled ?? this.shuffled, + collections: collections ?? this.collections, ); @override String toString() { return (StringBuffer('AudioPlayerStateTableData(') ..write('id: $id, ') ..write('playing: $playing, ') - ..write('volume: $volume, ') ..write('loopMode: $loopMode, ') - ..write('shuffled: $shuffled') + ..write('shuffled: $shuffled, ') + ..write('collections: $collections') ..write(')')) .toString(); } @override - int get hashCode => Object.hash(id, playing, volume, loopMode, shuffled); + int get hashCode => Object.hash(id, playing, loopMode, shuffled, collections); @override bool operator ==(Object other) => identical(this, other) || (other is AudioPlayerStateTableData && other.id == this.id && other.playing == this.playing && - other.volume == this.volume && other.loopMode == this.loopMode && - other.shuffled == this.shuffled); + other.shuffled == this.shuffled && + other.collections == this.collections); } class AudioPlayerStateTableCompanion extends UpdateCompanion { final Value id; final Value playing; - final Value volume; final Value loopMode; final Value shuffled; + final Value> collections; const AudioPlayerStateTableCompanion({ this.id = const Value.absent(), this.playing = const Value.absent(), - this.volume = const Value.absent(), this.loopMode = const Value.absent(), this.shuffled = const Value.absent(), + this.collections = const Value.absent(), }); AudioPlayerStateTableCompanion.insert({ this.id = const Value.absent(), required bool playing, - required double volume, required PlaylistMode loopMode, required bool shuffled, + required List collections, }) : playing = Value(playing), - volume = Value(volume), loopMode = Value(loopMode), - shuffled = Value(shuffled); + shuffled = Value(shuffled), + collections = Value(collections); static Insertable custom({ Expression? id, Expression? playing, - Expression? volume, Expression? loopMode, Expression? shuffled, + Expression? collections, }) { return RawValuesInsertable({ if (id != null) 'id': id, if (playing != null) 'playing': playing, - if (volume != null) 'volume': volume, if (loopMode != null) 'loop_mode': loopMode, if (shuffled != null) 'shuffled': shuffled, + if (collections != null) 'collections': collections, }); } AudioPlayerStateTableCompanion copyWith( {Value? id, Value? playing, - Value? volume, Value? loopMode, - Value? shuffled}) { + Value? shuffled, + Value>? collections}) { return AudioPlayerStateTableCompanion( id: id ?? this.id, playing: playing ?? this.playing, - volume: volume ?? this.volume, loopMode: loopMode ?? this.loopMode, shuffled: shuffled ?? this.shuffled, + collections: collections ?? this.collections, ); } @@ -2854,9 +2858,6 @@ class AudioPlayerStateTableCompanion if (playing.present) { map['playing'] = Variable(playing.value); } - if (volume.present) { - map['volume'] = Variable(volume.value); - } if (loopMode.present) { map['loop_mode'] = Variable( $AudioPlayerStateTableTable.$converterloopMode.toSql(loopMode.value)); @@ -2864,6 +2865,11 @@ class AudioPlayerStateTableCompanion if (shuffled.present) { map['shuffled'] = Variable(shuffled.value); } + if (collections.present) { + map['collections'] = Variable($AudioPlayerStateTableTable + .$convertercollections + .toSql(collections.value)); + } return map; } @@ -2872,9 +2878,9 @@ class AudioPlayerStateTableCompanion return (StringBuffer('AudioPlayerStateTableCompanion(') ..write('id: $id, ') ..write('playing: $playing, ') - ..write('volume: $volume, ') ..write('loopMode: $loopMode, ') - ..write('shuffled: $shuffled') + ..write('shuffled: $shuffled, ') + ..write('collections: $collections') ..write(')')) .toString(); } @@ -4591,17 +4597,17 @@ typedef $$AudioPlayerStateTableTableInsertCompanionBuilder = AudioPlayerStateTableCompanion Function({ Value id, required bool playing, - required double volume, required PlaylistMode loopMode, required bool shuffled, + required List collections, }); typedef $$AudioPlayerStateTableTableUpdateCompanionBuilder = AudioPlayerStateTableCompanion Function({ Value id, Value playing, - Value volume, Value loopMode, Value shuffled, + Value> collections, }); class $$AudioPlayerStateTableTableTableManager extends RootTableManager< @@ -4627,30 +4633,30 @@ class $$AudioPlayerStateTableTableTableManager extends RootTableManager< getUpdateCompanionBuilder: ({ Value id = const Value.absent(), Value playing = const Value.absent(), - Value volume = const Value.absent(), Value loopMode = const Value.absent(), Value shuffled = const Value.absent(), + Value> collections = const Value.absent(), }) => AudioPlayerStateTableCompanion( id: id, playing: playing, - volume: volume, loopMode: loopMode, shuffled: shuffled, + collections: collections, ), getInsertCompanionBuilder: ({ Value id = const Value.absent(), required bool playing, - required double volume, required PlaylistMode loopMode, required bool shuffled, + required List collections, }) => AudioPlayerStateTableCompanion.insert( id: id, playing: playing, - volume: volume, loopMode: loopMode, shuffled: shuffled, + collections: collections, ), )); } @@ -4681,11 +4687,6 @@ class $$AudioPlayerStateTableTableFilterComposer builder: (column, joinBuilders) => ColumnFilters(column, joinBuilders: joinBuilders)); - ColumnFilters get volume => $state.composableBuilder( - column: $state.table.volume, - builder: (column, joinBuilders) => - ColumnFilters(column, joinBuilders: joinBuilders)); - ColumnWithTypeConverterFilters get loopMode => $state.composableBuilder( column: $state.table.loopMode, @@ -4698,6 +4699,13 @@ class $$AudioPlayerStateTableTableFilterComposer builder: (column, joinBuilders) => ColumnFilters(column, joinBuilders: joinBuilders)); + ColumnWithTypeConverterFilters, List, String> + get collections => $state.composableBuilder( + column: $state.table.collections, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + ComposableFilter playlistTableRefs( ComposableFilter Function($$PlaylistTableTableFilterComposer f) f) { final $$PlaylistTableTableFilterComposer composer = $state.composerBuilder( @@ -4725,11 +4733,6 @@ class $$AudioPlayerStateTableTableOrderingComposer builder: (column, joinBuilders) => ColumnOrderings(column, joinBuilders: joinBuilders)); - ColumnOrderings get volume => $state.composableBuilder( - column: $state.table.volume, - builder: (column, joinBuilders) => - ColumnOrderings(column, joinBuilders: joinBuilders)); - ColumnOrderings get loopMode => $state.composableBuilder( column: $state.table.loopMode, builder: (column, joinBuilders) => @@ -4739,6 +4742,11 @@ class $$AudioPlayerStateTableTableOrderingComposer column: $state.table.shuffled, builder: (column, joinBuilders) => ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get collections => $state.composableBuilder( + column: $state.table.collections, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); } typedef $$PlaylistTableTableInsertCompanionBuilder = PlaylistTableCompanion diff --git a/lib/models/database/tables/audio_player_state.dart b/lib/models/database/tables/audio_player_state.dart index 45f5ffd9..3e49cf6f 100644 --- a/lib/models/database/tables/audio_player_state.dart +++ b/lib/models/database/tables/audio_player_state.dart @@ -3,9 +3,9 @@ part of '../database.dart'; class AudioPlayerStateTable extends Table { IntColumn get id => integer().autoIncrement()(); BoolColumn get playing => boolean()(); - RealColumn get volume => real()(); TextColumn get loopMode => textEnum()(); BoolColumn get shuffled => boolean()(); + TextColumn get collections => text().map(const StringListConverter())(); } class PlaylistTable extends Table { diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index a071ac04..f9f70c66 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -12,7 +12,7 @@ import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/pages/album/album.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -30,10 +30,10 @@ class AlbumCard extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(proxyPlaylistProvider); + final playlist = ref.watch(audioPlayerProvider); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final historyNotifier = ref.read(playbackHistoryProvider.notifier); bool isPlaylistPlaying = useMemoized( @@ -59,7 +59,7 @@ class AlbumCard extends HookConsumerWidget { ), margin: const EdgeInsets.symmetric(horizontal: 10), isPlaying: isPlaylistPlaying, - isLoading: (isPlaylistPlaying && playlist.isFetching == true) || + isLoading: (isPlaylistPlaying && playlistNotifier.isFetching()) || updating.value, title: album.name!, description: diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index 66344792..d75df796 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -25,7 +25,7 @@ import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; @@ -47,7 +47,7 @@ class PlayerView extends HookConsumerWidget { final auth = ref.watch(authenticationProvider); final sourcedCurrentTrack = ref.watch(activeSourcedTrackProvider); final currentActiveTrack = - ref.watch(proxyPlaylistProvider.select((s) => s.activeTrack)); + ref.watch(audioPlayerProvider.select((s) => s.activeTrack)); final currentTrack = sourcedCurrentTrack ?? currentActiveTrack; final isLocalTrack = currentTrack is LocalTrack; final mediaQuery = MediaQuery.of(context); @@ -309,15 +309,13 @@ class PlayerView extends HookConsumerWidget { builder: (context) => Consumer( builder: (context, ref, _) { final playlist = ref.watch( - proxyPlaylistProvider, - ); - final playlistNotifier = - ref.read( - proxyPlaylistProvider - .notifier, + audioPlayerProvider, ); + final playlistNotifier = ref + .read(audioPlayerProvider + .notifier); return PlayerQueue - .fromProxyPlaylistNotifier( + .fromAudioPlayerNotifier( floating: false, playlist: playlist, notifier: playlistNotifier, @@ -328,8 +326,9 @@ class PlayerView extends HookConsumerWidget { } : null), ), - if (auth != null) const SizedBox(width: 10), - if (auth != null) + if (auth.asData?.value != null) + const SizedBox(width: 10), + if (auth.asData?.value != null) Expanded( child: OutlinedButton.icon( label: Text(context.l10n.lyrics), diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart index 8fd434ad..8a7b3e83 100644 --- a/lib/modules/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -14,7 +14,7 @@ import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/sleep_timer_provider.dart'; class PlayerActions extends HookConsumerWidget { @@ -33,7 +33,7 @@ class PlayerActions extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(proxyPlaylistProvider); + final playlist = ref.watch(audioPlayerProvider); final isLocalTrack = playlist.activeTrack is LocalTrack; ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); @@ -129,7 +129,9 @@ class PlayerActions extends HookConsumerWidget { ? () => downloader.addToQueue(playlist.activeTrack!) : null, ), - if (playlist.activeTrack != null && !isLocalTrack && auth != null) + if (playlist.activeTrack != null && + !isLocalTrack && + auth.asData?.value != null) TrackHeartButton(track: playlist.activeTrack!), AdaptivePopSheetList( offset: Offset(0, -50 * (sleepTimerEntries.values.length + 2)), diff --git a/lib/modules/player/player_controls.dart b/lib/modules/player/player_controls.dart index a1a3ffcf..c5ef82d6 100644 --- a/lib/modules/player/player_controls.dart +++ b/lib/modules/player/player_controls.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/modules/player/use_progress.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; class PlayerControls extends HookConsumerWidget { @@ -43,8 +43,8 @@ class PlayerControls extends HookConsumerWidget { SeekIntent: SeekAction(), }, []); - final playlist = ref.watch(proxyPlaylistProvider); - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; @@ -132,7 +132,7 @@ class PlayerControls extends HookConsumerWidget { // than total duration. Keeping it resolved value: progress.value.toDouble(), secondaryTrackValue: bufferProgress, - onChanged: playlist.isFetching == true + onChanged: playlistNotifier.isFetching() ? null : (v) { progress.value = v; @@ -183,7 +183,7 @@ class PlayerControls extends HookConsumerWidget { : context.l10n.shuffle_playlist, icon: const Icon(SpotubeIcons.shuffle), style: shuffled ? activeButtonStyle : buttonStyle, - onPressed: playlist.isFetching == true + onPressed: playlistNotifier.isFetching() ? null : () { if (shuffled) { @@ -198,15 +198,15 @@ class PlayerControls extends HookConsumerWidget { tooltip: context.l10n.previous_track, icon: const Icon(SpotubeIcons.skipBack), style: buttonStyle, - onPressed: playlist.isFetching == true + onPressed: playlistNotifier.isFetching() ? null - : playlistNotifier.previous, + : audioPlayer.skipToPrevious, ), IconButton( tooltip: playing ? context.l10n.pause_playback : context.l10n.resume_playback, - icon: playlist.isFetching == true + icon: playlistNotifier.isFetching() ? SizedBox( height: 20, width: 20, @@ -219,7 +219,7 @@ class PlayerControls extends HookConsumerWidget { playing ? SpotubeIcons.pause : SpotubeIcons.play, ), style: resumePauseStyle, - onPressed: playlist.isFetching == true + onPressed: playlistNotifier.isFetching() ? null : Actions.handler( context, @@ -230,9 +230,9 @@ class PlayerControls extends HookConsumerWidget { tooltip: context.l10n.next_track, icon: const Icon(SpotubeIcons.skipForward), style: buttonStyle, - onPressed: playlist.isFetching == true + onPressed: playlistNotifier.isFetching() ? null - : playlistNotifier.next, + : audioPlayer.skipToNext, ), StreamBuilder( stream: audioPlayer.loopModeStream, @@ -253,7 +253,7 @@ class PlayerControls extends HookConsumerWidget { loopMode == PlaylistMode.loop ? activeButtonStyle : buttonStyle, - onPressed: playlist.isFetching == true + onPressed: playlistNotifier.isFetching() ? null : () async { await audioPlayer.setLoopMode(loopMode); diff --git a/lib/modules/player/player_overlay.dart b/lib/modules/player/player_overlay.dart index 084de425..c1b285ee 100644 --- a/lib/modules/player/player_overlay.dart +++ b/lib/modules/player/player_overlay.dart @@ -11,7 +11,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/intents.dart'; import 'package:spotube/modules/player/use_progress.dart'; import 'package:spotube/modules/player/player.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; class PlayerOverlay extends HookConsumerWidget { @@ -24,8 +24,8 @@ class PlayerOverlay extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); - final playlist = ref.watch(proxyPlaylistProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final playlist = ref.watch(audioPlayerProvider); final canShow = playlist.activeTrack != null; final playing = @@ -127,14 +127,14 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipBack, color: textColor, ), - onPressed: playlist.isFetching + onPressed: playlistNotifier.isFetching() ? null - : playlistNotifier.previous, + : audioPlayer.skipToPrevious, ), Consumer( builder: (context, ref, _) { return IconButton( - icon: playlist.isFetching + icon: playlistNotifier.isFetching() ? const SizedBox( height: 20, width: 20, @@ -158,9 +158,9 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipForward, color: textColor, ), - onPressed: playlist.isFetching + onPressed: playlistNotifier.isFetching() ? null - : playlistNotifier.next, + : audioPlayer.skipToNext, ), ], ), diff --git a/lib/modules/player/player_queue.dart b/lib/modules/player/player_queue.dart index cf16e9a3..2431d82e 100644 --- a/lib/modules/player/player_queue.dart +++ b/lib/modules/player/player_queue.dart @@ -18,12 +18,12 @@ import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/state.dart'; class PlayerQueue extends HookConsumerWidget { final bool floating; - final ProxyPlaylist playlist; + final AudioPlayerState playlist; final Future Function(Track track) onJump; final Future Function(String trackId) onRemove; @@ -40,10 +40,10 @@ class PlayerQueue extends HookConsumerWidget { super.key, }); - PlayerQueue.fromProxyPlaylistNotifier({ + PlayerQueue.fromAudioPlayerNotifier({ this.floating = true, required this.playlist, - required ProxyPlaylistNotifier notifier, + required AudioPlayerNotifier notifier, super.key, }) : onJump = notifier.jumpToTrack, onRemove = notifier.removeTrack, @@ -93,11 +93,10 @@ class PlayerQueue extends HookConsumerWidget { ); useEffect(() { - if (playlist.active == null) return null; + if (playlist.activeTrack == null) return null; - if (playlist.active! < 0) return; controller.scrollToIndex( - playlist.active!, + playlist.playlist.index, preferPosition: AutoScrollPosition.middle, ); return null; diff --git a/lib/modules/player/player_track_details.dart b/lib/modules/player/player_track_details.dart index da58e3b1..d722830e 100644 --- a/lib/modules/player/player_track_details.dart +++ b/lib/modules/player/player_track_details.dart @@ -9,7 +9,7 @@ import 'package:spotube/components/links/link_text.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { @@ -21,7 +21,7 @@ class PlayerTrackDetails extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final playback = ref.watch(proxyPlaylistProvider); + final playback = ref.watch(audioPlayerProvider); return Row( children: [ diff --git a/lib/modules/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart index a6136e62..8592f1e3 100644 --- a/lib/modules/player/sibling_tracks_sheet.dart +++ b/lib/modules/player/sibling_tracks_sheet.dart @@ -15,7 +15,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -53,7 +53,8 @@ class SiblingTracksSheet extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final playlist = ref.watch(proxyPlaylistProvider); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final preferences = ref.watch(userPreferencesProvider); final isSearching = useState(false); @@ -129,13 +130,13 @@ class SiblingTracksSheet extends HookConsumerWidget { ]); final siblings = useMemoized( - () => playlist.isFetching == false + () => playlistNotifier.isFetching() ? [ (activeTrack as SourcedTrack).sourceInfo, ...activeTrack.siblings, ] : [], - [playlist.isFetching, activeTrack], + [activeTrack], ); final borderRadius = floating @@ -175,12 +176,12 @@ class SiblingTracksSheet extends HookConsumerWidget { Text(" • ${sourceInfo.artist}"), ], ), - enabled: playlist.isFetching != true, - selected: playlist.isFetching != true && + enabled: !playlistNotifier.isFetching(), + selected: !playlistNotifier.isFetching() && sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id, selectedTileColor: theme.popupMenuTheme.color, onTap: () { - if (playlist.isFetching == false && + if (!playlistNotifier.isFetching() && sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) { activeTrackNotifier.swapSibling(sourceInfo); Navigator.of(context).pop(); @@ -188,7 +189,7 @@ class SiblingTracksSheet extends HookConsumerWidget { }, ); }, - [playlist.isFetching, activeTrack, siblings], + [activeTrack, siblings], ); final mediaQuery = MediaQuery.of(context); diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index 7c11eca6..c4164701 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -9,7 +9,7 @@ import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -22,8 +22,8 @@ class PlaylistCard extends HookConsumerWidget { }); @override Widget build(BuildContext context, ref) { - final playlistQueue = ref.watch(proxyPlaylistProvider); - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlistQueue = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final historyNotifier = ref.read(playbackHistoryProvider.notifier); final playing = @@ -65,8 +65,8 @@ class PlaylistCard extends HookConsumerWidget { placeholder: ImagePlaceholder.collection, ), isPlaying: isPlaylistPlaying, - isLoading: - (isPlaylistPlaying && playlistQueue.isFetching) || updating.value, + isLoading: (isPlaylistPlaying && playlistNotifier.isFetching()) || + updating.value, isOwner: playlist.owner?.id == me.asData?.value.id && me.asData?.value.id != null, onTap: () { diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart index a77ab6fe..e7dbacd2 100644 --- a/lib/modules/root/bottom_player.dart +++ b/lib/modules/root/bottom_player.dart @@ -19,7 +19,7 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/volume_provider.dart'; @@ -33,7 +33,7 @@ class BottomPlayer extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final auth = ref.watch(authenticationProvider); - final playlist = ref.watch(proxyPlaylistProvider); + final playlist = ref.watch(audioPlayerProvider); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); @@ -91,7 +91,7 @@ class BottomPlayer extends HookConsumerWidget { children: [ PlayerActions( extraActions: [ - if (auth != null) + if (auth.asData?.value != null) IconButton( tooltip: context.l10n.mini_player, icon: const Icon(SpotubeIcons.miniPlayer), diff --git a/lib/pages/artist/section/header.dart b/lib/pages/artist/section/header.dart index 7d7fa8ef..713e0d26 100644 --- a/lib/pages/artist/section/header.dart +++ b/lib/pages/artist/section/header.dart @@ -135,7 +135,7 @@ class ArtistPageHeader extends HookConsumerWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - if (auth != null) + if (auth.asData?.value != null) Consumer( builder: (context, ref, _) { final isFollowingQuery = ref diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index c9397c7b..d52ed470 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -9,7 +9,7 @@ import 'package:spotube/components/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class ArtistPageTopTracks extends HookConsumerWidget { @@ -21,8 +21,8 @@ class ArtistPageTopTracks extends HookConsumerWidget { final theme = Theme.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context); - final playlist = ref.watch(proxyPlaylistProvider); - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final topTracksQuery = ref.watch(artistTopTracksProvider(artistId)); final isPlaylistPlaying = playlist.containsTracks( diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart index 830e8a5d..16891bc1 100644 --- a/lib/pages/library/local_folder.dart +++ b/lib/pages/library/local_folder.dart @@ -17,7 +17,7 @@ import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; class LocalLibraryPage extends HookConsumerWidget { @@ -32,8 +32,8 @@ class LocalLibraryPage extends HookConsumerWidget { List tracks, { LocalTrack? currentTrack, }) async { - final playlist = ref.read(proxyPlaylistProvider); - final playback = ref.read(proxyPlaylistProvider.notifier); + final playlist = ref.read(audioPlayerProvider); + final playback = ref.read(audioPlayerProvider.notifier); currentTrack ??= tracks.first; final isPlaylistPlaying = playlist.containsTracks(tracks); if (!isPlaylistPlaying) { @@ -52,7 +52,7 @@ class LocalLibraryPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final sortBy = useState(SortBy.none); - final playlist = ref.watch(proxyPlaylistProvider); + final playlist = ref.watch(audioPlayerProvider); final trackSnapshot = ref.watch(localTracksProvider); final isPlaylistPlaying = playlist.containsTracks( trackSnapshot.asData?.value.values.flattened.toList() ?? []); diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index 90838300..3bdc3b52 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -11,7 +11,7 @@ import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/pages/playlist/playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistGenerateResultPage extends HookConsumerWidget { @@ -28,7 +28,7 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final router = GoRouter.of(context); final scaffoldMessenger = ScaffoldMessenger.of(context); - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final generatedPlaylist = ref.watch(generatePlaylistProvider(state)); @@ -81,9 +81,12 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { ? null : () async { await playlistNotifier.load( - generatedPlaylist.asData!.value.where( - (e) => selectedTracks.value.contains(e.id!), - ), + generatedPlaylist.asData!.value + .where( + (e) => selectedTracks.value + .contains(e.id!), + ) + .toList(), autoPlay: true, ); }, diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index c484046b..18ce6e28 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -18,7 +18,7 @@ import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -30,7 +30,7 @@ class LyricsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(proxyPlaylistProvider); + final playlist = ref.watch(audioPlayerProvider); String albumArt = useMemoized( () => (playlist.activeTrack?.album?.images).asUrlString( index: (playlist.activeTrack?.album?.images?.length ?? 1) - 1, @@ -62,7 +62,7 @@ class LyricsPage extends HookConsumerWidget { const Spacer(), Consumer( builder: (context, ref, child) { - final playback = ref.watch(proxyPlaylistProvider); + final playback = ref.watch(audioPlayerProvider); final lyric = ref.watch(syncedLyricsProvider(playback.activeTrack)); final providerName = lyric.asData?.value.provider; diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index f9659538..d9222059 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -15,7 +15,7 @@ import 'package:spotube/hooks/utils/use_force_update.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; import 'package:spotube/pages/lyrics/synced_lyrics.dart'; import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; @@ -31,7 +31,7 @@ class MiniLyricsPage extends HookConsumerWidget { final update = useForceUpdate(); final wasMaximized = useRef(false); - final playlistQueue = ref.watch(proxyPlaylistProvider); + final playlistQueue = ref.watch(audioPlayerProvider); final areaActive = useState(false); final hoverMode = useState(true); @@ -230,14 +230,13 @@ class MiniLyricsPage extends HookConsumerWidget { builder: (context) { return Consumer(builder: (context, ref, _) { final playlist = - ref.watch(proxyPlaylistProvider); + ref.watch(audioPlayerProvider); - return PlayerQueue - .fromProxyPlaylistNotifier( + return PlayerQueue.fromAudioPlayerNotifier( floating: true, playlist: playlist, notifier: ref - .read(proxyPlaylistProvider.notifier), + .read(audioPlayerProvider.notifier), ); }); }, diff --git a/lib/pages/lyrics/plain_lyrics.dart b/lib/pages/lyrics/plain_lyrics.dart index 5340e8fd..7c571d5f 100644 --- a/lib/pages/lyrics/plain_lyrics.dart +++ b/lib/pages/lyrics/plain_lyrics.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class PlainLyrics extends HookConsumerWidget { @@ -27,7 +27,7 @@ class PlainLyrics extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(proxyPlaylistProvider); + final playlist = ref.watch(audioPlayerProvider); final lyricsQuery = ref.watch(syncedLyricsProvider(playlist.activeTrack)); final mediaQuery = MediaQuery.of(context); final textTheme = Theme.of(context).textTheme; diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 8a2dd356..3294bab5 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -12,7 +12,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/modules/lyrics/use_synced_lyrics.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -32,7 +32,7 @@ class SyncedLyrics extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(proxyPlaylistProvider); + final playlist = ref.watch(audioPlayerProvider); final mediaQuery = MediaQuery.of(context); final controller = useAutoScrollController(); @@ -54,7 +54,7 @@ class SyncedLyrics extends HookConsumerWidget { final textTheme = Theme.of(context).textTheme; ref.listen( - proxyPlaylistProvider.select((s) => s.activeTrack), + audioPlayerProvider.select((s) => s.activeTrack), (previous, next) { controller.scrollToIndex(0); ref.read(syncedLyricsDelayProvider.notifier).state = 0; diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 93a84f0a..322a8731 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -16,7 +16,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/pages/home/home.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/server/routes/connect.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -201,11 +201,11 @@ class RootApp extends HookConsumerWidget { ), child: Consumer( builder: (context, ref, _) { - final playlist = ref.watch(proxyPlaylistProvider); + final playlist = ref.watch(audioPlayerProvider); final playlistNotifier = - ref.read(proxyPlaylistProvider.notifier); + ref.read(audioPlayerProvider.notifier); - return PlayerQueue.fromProxyPlaylistNotifier( + return PlayerQueue.fromAudioPlayerNotifier( floating: true, playlist: playlist, notifier: playlistNotifier, diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index 1bde2872..6ec8f685 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -8,7 +8,7 @@ import 'package:spotube/components/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class SearchTracksSection extends HookConsumerWidget { @@ -24,8 +24,8 @@ class SearchTracksSection extends HookConsumerWidget { ref.watch(searchProvider(SearchType.track).notifier); final tracks = searchTrack.asData?.value.items.cast() ?? []; - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); - final playlist = ref.watch(proxyPlaylistProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final playlist = ref.watch(audioPlayerProvider); final theme = Theme.of(context); return Column( diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index 1e9b2067..dc4defc8 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -14,7 +14,7 @@ import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/track_tile/track_options.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -34,8 +34,8 @@ class TrackPage extends HookConsumerWidget { final ThemeData(:textTheme, :colorScheme) = Theme.of(context); final mediaQuery = MediaQuery.of(context); - final playlist = ref.watch(proxyPlaylistProvider); - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final isActive = playlist.activeTrack?.id == trackId; diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index 747c78e6..258e15d8 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -1,13 +1,22 @@ +import 'dart:math'; + import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart' hide Track; import 'package:spotify/spotify.dart' hide Playlist; +import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/database/database.dart'; +import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/audio_player/state.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/discord_provider.dart'; +import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; class AudioPlayerNotifier extends Notifier { + BlackListNotifier get _blacklist => ref.read(blacklistProvider.notifier); + Future _syncSavedState() async { final database = ref.read(databaseProvider); @@ -18,9 +27,9 @@ class AudioPlayerNotifier extends Notifier { await database.into(database.audioPlayerStateTable).insert( AudioPlayerStateTableCompanion.insert( playing: audioPlayer.isPlaying, - volume: audioPlayer.volume, loopMode: audioPlayer.loopMode, shuffled: audioPlayer.isShuffled, + collections: [], id: const Value(0), ), ); @@ -28,7 +37,6 @@ class AudioPlayerNotifier extends Notifier { playerState = await database.select(database.audioPlayerStateTable).getSingle(); } else { - await audioPlayer.setVolume(playerState.volume); await audioPlayer.setLoopMode(playerState.loopMode); await audioPlayer.setShuffle(playerState.shuffled); } @@ -130,15 +138,6 @@ class AudioPlayerNotifier extends Notifier { ), ); }), - audioPlayer.volumeStream.listen((volume) async { - state = state.copyWith(volume: volume); - - await _updatePlayerState( - AudioPlayerStateTableCompanion( - volume: Value(volume), - ), - ); - }), audioPlayer.loopModeStream.listen((loopMode) async { state = state.copyWith(loopMode: loopMode); @@ -177,49 +176,141 @@ class AudioPlayerNotifier extends Notifier { playing: audioPlayer.isPlaying, playlist: audioPlayer.playlist, shuffled: audioPlayer.isShuffled, - volume: audioPlayer.volume, + collections: [], ); } + // Collection related methods + Future addCollections(List collectionIds) async { + state = state.copyWith(collections: [ + ...state.collections, + ...collectionIds, + ]); + + await _updatePlayerState( + AudioPlayerStateTableCompanion( + collections: Value(state.collections), + ), + ); + } + + Future addCollection(String collectionId) async { + await addCollections([collectionId]); + } + + Future removeCollections(List collectionIds) async { + state = state.copyWith( + collections: state.collections + .where((element) => !collectionIds.contains(element)) + .toList(), + ); + + await _updatePlayerState( + AudioPlayerStateTableCompanion( + collections: Value(state.collections), + ), + ); + } + + Future removeCollection(String collectionId) async { + await removeCollections([collectionId]); + } + // Tracks related methods + Future addTracksAtFirst(Iterable tracks) async { + if (state.tracks.length == 1) { + return addTracks(tracks); + } + + tracks = _blacklist.filter(tracks).toList() as List; + + for (int i = 0; i < tracks.length; i++) { + final track = tracks.elementAt(i); + + await audioPlayer.addTrackAt( + SpotubeMedia(track), + max(state.playlist.index, 0) + i + 1, + ); + } + } + Future addTrack(Track track) async { + if (_blacklist.contains(track)) return; await audioPlayer.addTrack(SpotubeMedia(track)); } Future addTracks(Iterable tracks) async { + tracks = _blacklist.filter(tracks).toList() as List; for (final track in tracks) { - await addTrack(track); + await audioPlayer.addTrack(SpotubeMedia(track)); } } - Future removeTrack(Track track) async { - final index = state.tracks.indexWhere((element) => element == track); + Future removeTrack(String trackId) async { + final index = state.tracks.indexWhere((element) => element.id == trackId); if (index == -1) return; await audioPlayer.removeTrack(index); } - Future removeTracks(Iterable tracks) async { - for (final track in tracks) { - await removeTrack(track); + Future removeTracks(Iterable trackIds) async { + for (final trackId in trackIds) { + await removeTrack(trackId); } } Future load( - List track, { - required int initialIndex, + List tracks, { + int initialIndex = 0, bool autoPlay = false, }) async { + tracks = _blacklist.filter(tracks).toList() as List; + + // Giving the initial track a boost so MediaKit won't skip + // because of timeout + final intendedActiveTrack = tracks.elementAt(initialIndex); + if (intendedActiveTrack is! LocalTrack) { + await ref.read(sourcedTrackProvider(intendedActiveTrack).future); + } + await audioPlayer.openPlaylist( - track.map((t) => SpotubeMedia(t)).toList(), + tracks.asMediaList(), initialIndex: initialIndex, autoPlay: autoPlay, ); } + + Future jumpToTrack(Track track) async { + final index = + state.tracks.toList().indexWhere((element) => element.id == track.id); + if (index == -1) return; + await audioPlayer.jumpTo(index); + } + + Future moveTrack(int oldIndex, int newIndex) async { + if (oldIndex == newIndex || + newIndex < 0 || + oldIndex < 0 || + newIndex > state.tracks.length - 1 || + oldIndex > state.tracks.length - 1) return; + + await audioPlayer.moveTrack(oldIndex, newIndex); + } + + bool isFetching() { + if (state.activeTrack == null) return false; + return ref.read(sourcedTrackProvider(state.activeTrack!)).isLoading; + } + + Future stop() async { + await audioPlayer.stop(); + ref.read(discordProvider.notifier).clear(); + } } -final audioPlayerProvider = NotifierProvider( +final audioPlayerProvider = + NotifierProvider( () => AudioPlayerNotifier(), -); \ No newline at end of file +); diff --git a/lib/provider/proxy_playlist/player_listeners.dart b/lib/provider/audio_player/audio_player_streams.dart similarity index 51% rename from lib/provider/proxy_playlist/player_listeners.dart rename to lib/provider/audio_player/audio_player_streams.dart index 2c1423a5..d5473dd5 100644 --- a/lib/provider/proxy_playlist/player_listeners.dart +++ b/lib/provider/audio_player/audio_player_streams.dart @@ -1,19 +1,53 @@ -// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - import 'dart:async'; -import 'package:spotube/services/logger/logger.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/state.dart'; +import 'package:spotube/provider/discord_provider.dart'; +import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; -import 'package:spotube/provider/proxy_playlist/skip_segments.dart'; +import 'package:spotube/provider/skip_segments/skip_segments.dart'; +import 'package:spotube/provider/scrobbler/scrobbler.dart'; import 'package:spotube/provider/server/sourced_track.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/audio_services/audio_services.dart'; +import 'package:spotube/services/logger/logger.dart'; + +class AudioPlayerStreamListeners { + final Ref ref; + late final AudioServices notificationService; + AudioPlayerStreamListeners(this.ref) { + AudioServices.create(ref, ref.read(audioPlayerProvider.notifier)).then( + (value) => notificationService = value, + ); + + final subscriptions = [ + subscribeToPlaylist(), + subscribeToSkipSponsor(), + subscribeToScrobbleChanged(), + subscribeToPosition(), + subscribeToPlayerError(), + ]; + + ref.onDispose(() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + }); + } + + ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); + UserPreferences get preferences => ref.read(userPreferencesProvider); + Discord get discord => ref.read(discordProvider); + AudioPlayerState get audioPlayerState => ref.read(audioPlayerProvider); + PlaybackHistoryNotifier get history => + ref.read(playbackHistoryProvider.notifier); -extension ProxyPlaylistListeners on ProxyPlaylistNotifier { Future updatePalette() async { final palette = ref.read(paletteProvider); if (!preferences.albumColorSync) { @@ -21,11 +55,12 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { return; } return Future.microtask(() async { - if (playlist.activeTrack == null) return; + final activeTrack = ref.read(audioPlayerProvider).activeTrack; + if (activeTrack == null) return; final palette = await PaletteGenerator.fromImageProvider( UniversalImage.imageProvider( - (playlist.activeTrack?.album?.images).asUrlString( + (activeTrack.album?.images).asUrlString( placeholder: ImagePlaceholder.albumArt, ), height: 50, @@ -38,15 +73,8 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { StreamSubscription subscribeToPlaylist() { return audioPlayer.playlistStream.listen((mpvPlaylist) { - state = playlist.copyWith( - tracks: mpvPlaylist.medias - .map((media) => SpotubeMedia.fromMedia(media).track) - .toSet(), - active: mpvPlaylist.index, - ); - - notificationService.addTrack(playlist.activeTrack!); - discord.updatePresence(playlist.activeTrack!); + notificationService.addTrack(audioPlayerState.activeTrack!); + discord.updatePresence(audioPlayerState.activeTrack!); updatePalette(); }); } @@ -72,18 +100,18 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { String? lastScrobbled; return audioPlayer.positionStream.listen((position) { try { - final uid = playlist.activeTrack is LocalTrack - ? (playlist.activeTrack as LocalTrack).path - : playlist.activeTrack?.id; + final uid = audioPlayerState.activeTrack is LocalTrack + ? (audioPlayerState.activeTrack as LocalTrack).path + : audioPlayerState.activeTrack?.id; - if (playlist.activeTrack == null || + if (audioPlayerState.activeTrack == null || lastScrobbled == uid || position.inSeconds < 30) { return; } - scrobbler.scrobble(playlist.activeTrack!); - history.addTrack(playlist.activeTrack!); + scrobbler.scrobble(audioPlayerState.activeTrack!); + history.addTrack(audioPlayerState.activeTrack!); lastScrobbled = uid; } catch (e, stack) { AppLogger.reportError(e, stack); @@ -95,9 +123,13 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { String lastTrack = ""; // used to prevent multiple calls to the same track return audioPlayer.positionStream.listen((event) async { if (event < const Duration(seconds: 3) || - playlist.active == null || - playlist.active == playlist.tracks.length - 1) return; - final nextTrack = playlist.tracks.elementAt(playlist.active! + 1); + audioPlayerState.playlist.index == -1 || + audioPlayerState.playlist.index == + audioPlayerState.tracks.length - 1) { + return; + } + final nextTrack = audioPlayerState.tracks + .elementAt(audioPlayerState.playlist.index + 1); if (lastTrack == nextTrack.id || nextTrack is LocalTrack) return; @@ -113,3 +145,6 @@ extension ProxyPlaylistListeners on ProxyPlaylistNotifier { return audioPlayer.errorStream.listen((event) {}); } } + +final audioPlayerStreamListenersProvider = + Provider(AudioPlayerStreamListeners.new); diff --git a/lib/provider/audio_player/state.dart b/lib/provider/audio_player/state.dart index 3c874011..685ce112 100644 --- a/lib/provider/audio_player/state.dart +++ b/lib/provider/audio_player/state.dart @@ -4,39 +4,97 @@ import 'package:spotube/services/audio_player/audio_player.dart'; class AudioPlayerState { final bool playing; - final double volume; final PlaylistMode loopMode; final bool shuffled; final Playlist playlist; final List tracks; + final List collections; AudioPlayerState({ required this.playing, - required this.volume, required this.loopMode, required this.shuffled, required this.playlist, + required this.collections, List? tracks, }) : tracks = tracks ?? playlist.medias .map((media) => SpotubeMedia.fromMedia(media).track) .toList(); + factory AudioPlayerState.fromJson(Map json) { + return AudioPlayerState( + playing: json['playing'], + loopMode: PlaylistMode.values.firstWhere( + (e) => e.name == json['loopMode'], + orElse: () => audioPlayer.loopMode, + ), + shuffled: json['shuffled'], + playlist: Playlist( + json['playlist']['medias'] + .map((media) => Media( + media['uri'], + extras: media['extras'], + httpHeaders: media['httpHeaders'], + )) + .toList(), + index: json['playlist']['index'], + ), + collections: List.from(json['collections']), + ); + } + + Map toJson() { + return { + 'playing': playing, + 'loopMode': loopMode.name, + 'shuffled': shuffled, + 'playlist': { + 'medias': playlist.medias + .map((media) => { + 'uri': media.uri, + 'extras': media.extras, + 'httpHeaders': media.httpHeaders, + }) + .toList(), + 'index': playlist.index, + }, + 'collections': collections, + }; + } + AudioPlayerState copyWith({ bool? playing, - double? volume, PlaylistMode? loopMode, bool? shuffled, Playlist? playlist, + List? collections, }) { return AudioPlayerState( playing: playing ?? this.playing, - volume: volume ?? this.volume, loopMode: loopMode ?? this.loopMode, shuffled: shuffled ?? this.shuffled, playlist: playlist ?? this.playlist, + collections: collections ?? this.collections, tracks: playlist == null ? tracks : null, ); } + + Track? get activeTrack { + if (playlist.index == -1) return null; + return tracks.elementAtOrNull(playlist.index); + } + + bool containsTrack(Track track) { + return tracks.any((t) => t.id == track.id); + } + + bool containsTracks(List tracks) { + return tracks.every(containsTrack); + } + + bool containsCollection(String collectionId) { + return collections.contains(collectionId); + } } diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart index c6014445..28eb131b 100644 --- a/lib/provider/connect/connect.dart +++ b/lib/provider/connect/connect.dart @@ -1,13 +1,14 @@ import 'dart:convert'; import 'package:media_kit/media_kit.dart' hide Track; +import 'package:spotube/provider/audio_player/state.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart' hide Playlist; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/status.dart' as status; @@ -31,8 +32,14 @@ final loopModeProvider = StateProvider( (ref) => PlaylistMode.none, ); -final queueProvider = StateProvider( - (ref) => ProxyPlaylist({}), +final queueProvider = StateProvider( + (ref) => AudioPlayerState( + playing: audioPlayer.isPlaying, + loopMode: audioPlayer.loopMode, + shuffled: audioPlayer.isShuffled, + playlist: audioPlayer.playlist, + collections: [], + ), ); final volumeProvider = StateProvider( diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index f90db54a..29c53762 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/env.dart'; import 'package:spotube/extensions/artist_simple.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; @@ -55,7 +55,7 @@ final discordProvider = ChangeNotifierProvider( (ref) { final isEnabled = ref.watch(userPreferencesProvider.select((s) => s.discordPresence)); - final playback = ref.read(proxyPlaylistProvider); + final playback = ref.read(audioPlayerProvider); final discord = Discord(isEnabled); if (playback.activeTrack != null) { diff --git a/lib/provider/proxy_playlist/proxy_playlist.dart b/lib/provider/proxy_playlist/proxy_playlist.dart deleted file mode 100644 index 9f371b7a..00000000 --- a/lib/provider/proxy_playlist/proxy_playlist.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/models/local_track.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; - -class ProxyPlaylist { - final Set tracks; - final Set collections; - final int? active; - - ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]); - - factory ProxyPlaylist.fromJson( - Map json, - ) { - return ProxyPlaylist( - List.castFrom>( - json['tracks'] ?? >[], - ).map((t) => _makeAppropriateTrack(t)).toSet(), - json['active'] as int?, - json['collections'] == null - ? {} - : (json['collections'] as List).toSet().cast(), - ); - } - - factory ProxyPlaylist.fromJsonRaw(Map json) => ProxyPlaylist( - json['tracks'] == null - ? {} - : (json['tracks'] as List).map((t) => Track.fromJson(t)).toSet(), - json['active'] as int?, - json['collections'] == null - ? {} - : (json['collections'] as List).toSet().cast(), - ); - - Track? get activeTrack => - active == null || active == -1 ? null : tracks.elementAtOrNull(active!); - - bool get isFetching => activeTrack == null && tracks.isNotEmpty; - - bool containsCollection(String collection) { - return collections.contains(collection); - } - - bool containsTrack(TrackSimple track) { - return tracks.firstWhereOrNull((element) { - if (element is LocalTrack && track is LocalTrack) { - return element.path == track.path; - } - - return element.id == track.id; - }) != - null; - } - - bool containsTracks(Iterable tracks) { - if (tracks.isEmpty) return false; - return tracks.every(containsTrack); - } - - static Track _makeAppropriateTrack(Map track) { - if (track.containsKey("path")) { - return LocalTrack.fromJson(track); - } else { - return Track.fromJson(track); - } - } - - /// To make sure proper instance method is used for JSON serialization - /// Otherwise default super.toJson() is used - static Map _makeAppropriateTrackJson(Track track) { - return switch (track) { - // ignore: unnecessary_cast - LocalTrack() => (track as LocalTrack).toJson(), - // ignore: unnecessary_cast - SourcedTrack() => (track as SourcedTrack).toJson(), - _ => track.toJson(), - }; - } - - Map toJson() { - return { - 'tracks': tracks.map(_makeAppropriateTrackJson).toList(), - 'active': active, - 'collections': collections.toList(), - }; - } - - ProxyPlaylist copyWith({ - Set? tracks, - int? active, - Set? collections, - }) { - return ProxyPlaylist( - tracks ?? this.tracks, - active ?? this.active, - collections ?? this.collections, - ); - } -} diff --git a/lib/provider/proxy_playlist/proxy_playlist_provider.dart b/lib/provider/proxy_playlist/proxy_playlist_provider.dart deleted file mode 100644 index 067d8d44..00000000 --- a/lib/provider/proxy_playlist/proxy_playlist_provider.dart +++ /dev/null @@ -1,215 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/extensions/track.dart'; -import 'package:spotube/models/local_track.dart'; - -import 'package:spotube/provider/blacklist_provider.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/scrobbler/scrobbler.dart'; -import 'package:spotube/provider/server/sourced_track.dart'; -import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; - -import 'package:spotube/services/audio_player/audio_player.dart'; -import 'package:spotube/services/audio_services/audio_services.dart'; -import 'package:spotube/provider/discord_provider.dart'; - -import 'package:spotube/utils/persisted_state_notifier.dart'; - -class ProxyPlaylistNotifier extends PersistedStateNotifier { - final Ref ref; - late final AudioServices notificationService; - - ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); - UserPreferences get preferences => ref.read(userPreferencesProvider); - ProxyPlaylist get playlist => state; - BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier); - Discord get discord => ref.read(discordProvider); - PlaybackHistoryNotifier get history => - ref.read(playbackHistoryProvider.notifier); - - List _subscriptions = []; - - ProxyPlaylistNotifier(this.ref) : super(ProxyPlaylist({}), "playlist") { - AudioServices.create(ref, this).then( - (value) => notificationService = value, - ); - - _subscriptions = [ - // These are subscription methods from player_listeners.dart - subscribeToPlaylist(), - subscribeToSkipSponsor(), - subscribeToPosition(), - subscribeToScrobbleChanged(), - ]; - } - // Basic methods for adding or removing tracks to playlist - - Future addTrack(Track track) async { - if (blacklist.contains(track)) return; - await audioPlayer.addTrack(SpotubeMedia(track)); - } - - Future addTracks(Iterable tracks) async { - tracks = blacklist.filter(tracks).toList() as List; - for (final track in tracks) { - await audioPlayer.addTrack(SpotubeMedia(track)); - } - } - - void addCollection(String collectionId) { - state = state.copyWith(collections: { - ...state.collections, - collectionId, - }); - } - - void removeCollection(String collectionId) { - state = state.copyWith(collections: { - ...state.collections..remove(collectionId), - }); - } - - Future removeTrack(String trackId) async { - final trackIndex = - state.tracks.toList().indexWhere((element) => element.id == trackId); - if (trackIndex == -1) return; - await audioPlayer.removeTrack(trackIndex); - } - - Future removeTracks(Iterable tracksIds) async { - final tracks = state.tracks.map((t) => t.id!).toList(); - - for (final track in tracks) { - final index = tracks.indexOf(track); - if (index == -1) continue; - await audioPlayer.removeTrack(index); - } - } - - Future load( - Iterable tracks, { - int initialIndex = 0, - bool autoPlay = false, - }) async { - tracks = blacklist.filter(tracks).toList() as List; - - state = state.copyWith(collections: {}); - - // Giving the initial track a boost so MediaKit won't skip - // because of timeout - final intendedActiveTrack = tracks.elementAt(initialIndex); - if (intendedActiveTrack is! LocalTrack) { - await ref.read(sourcedTrackProvider(intendedActiveTrack).future); - } - - await audioPlayer.openPlaylist( - tracks.asMediaList(), - initialIndex: initialIndex, - autoPlay: autoPlay, - ); - } - - Future jumpTo(int index) async { - await audioPlayer.jumpTo(index); - } - - Future jumpToTrack(Track track) async { - final index = - state.tracks.toList().indexWhere((element) => element.id == track.id); - if (index == -1) return; - await jumpTo(index); - } - - Future moveTrack(int oldIndex, int newIndex) async { - if (oldIndex == newIndex || - newIndex < 0 || - oldIndex < 0 || - newIndex > state.tracks.length - 1 || - oldIndex > state.tracks.length - 1) return; - - await audioPlayer.moveTrack(oldIndex, newIndex); - } - - Future addTracksAtFirst(Iterable tracks) async { - if (state.tracks.length == 1) { - return addTracks(tracks); - } - - tracks = blacklist.filter(tracks).toList() as List; - - for (int i = 0; i < tracks.length; i++) { - final track = tracks.elementAt(i); - - await audioPlayer.addTrackAt( - SpotubeMedia(track), - (state.active ?? 0) + i + 1, - ); - } - } - - Future next() async { - await audioPlayer.skipToNext(); - } - - Future previous() async { - await audioPlayer.skipToPrevious(); - } - - Future stop() async { - state = ProxyPlaylist({}); - await audioPlayer.stop(); - discord.clear(); - } - - @override - set state(state) { - super.state = state; - if (state.tracks.isEmpty && ref.read(paletteProvider) != null) { - ref.read(paletteProvider.notifier).state = null; - } else { - updatePalette(); - } - } - - @override - onInit() async { - if (state.tracks.isEmpty) return null; - final oldCollections = state.collections; - await load( - state.tracks, - initialIndex: max(state.active ?? 0, 0), - autoPlay: false, - ); - state = state.copyWith(collections: oldCollections); - } - - @override - FutureOr fromJson(Map json) { - return ProxyPlaylist.fromJson(json); - } - - @override - Map toJson() { - final json = state.toJson(); - return json; - } - - @override - void dispose() { - for (final subscription in _subscriptions) { - subscription.cancel(); - } - super.dispose(); - } -} - -final proxyPlaylistProvider = - StateNotifierProvider( - (ref) => ProxyPlaylistNotifier(ref), -); diff --git a/lib/provider/server/active_sourced_track.dart b/lib/provider/server/active_sourced_track.dart index 410b788c..685896ec 100644 --- a/lib/provider/server/active_sourced_track.dart +++ b/lib/provider/server/active_sourced_track.dart @@ -1,5 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/sourced_track/models/source_info.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -28,7 +28,7 @@ class ActiveSourcedTrackNotifier extends Notifier { state = newTrack; await audioPlayer.pause(); - final playbackNotifier = ref.read(proxyPlaylistProvider.notifier); + final playbackNotifier = ref.read(audioPlayerProvider.notifier); final oldActiveIndex = audioPlayer.currentIndex; await playbackNotifier.addTracksAtFirst([newTrack]); diff --git a/lib/provider/server/routes/connect.dart b/lib/provider/server/routes/connect.dart index eee3365e..a2fa70b8 100644 --- a/lib/provider/server/routes/connect.dart +++ b/lib/provider/server/routes/connect.dart @@ -9,7 +9,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/logger/logger.dart'; @@ -38,8 +38,8 @@ class ServerConnectRoutes { }); } - ProxyPlaylistNotifier get playbackNotifier => - ref.read(proxyPlaylistProvider.notifier); + AudioPlayerNotifier get audioPlayerNotifier => + ref.read(audioPlayerProvider.notifier); PlaybackHistoryNotifier get historyNotifier => ref.read(playbackHistoryProvider.notifier); Stream get connectClientStream => @@ -57,7 +57,7 @@ class ServerConnectRoutes { _connectClientStreamController.add(origin); ref.listen( - proxyPlaylistProvider, + audioPlayerProvider, (previous, next) { channel.sink.addEvent(WebSocketQueueEvent(next)); }, @@ -67,10 +67,10 @@ class ServerConnectRoutes { // because audioPlayer events doesn't fireImmediately channel.sink.addEvent(WebSocketPlayingEvent(audioPlayer.isPlaying)); channel.sink.addEvent( - WebSocketPositionEvent(await audioPlayer.position ?? Duration.zero), + WebSocketPositionEvent(audioPlayer.position), ); channel.sink.addEvent( - WebSocketDurationEvent(await audioPlayer.duration ?? Duration.zero), + WebSocketDurationEvent(audioPlayer.duration), ); channel.sink.addEvent(WebSocketShuffleEvent(audioPlayer.isShuffled)); channel.sink.addEvent(WebSocketLoopEvent(audioPlayer.loopMode)); @@ -116,14 +116,14 @@ class ServerConnectRoutes { ); event.onLoad((event) async { - await playbackNotifier.load( + await audioPlayerNotifier.load( event.data.tracks, autoPlay: true, initialIndex: event.data.initialIndex ?? 0, ); if (event.data.collectionId == null) return; - playbackNotifier.addCollection(event.data.collectionId!); + audioPlayerNotifier.addCollection(event.data.collectionId!); if (event.data.collection is AlbumSimple) { historyNotifier .addAlbums([event.data.collection as AlbumSimple]); @@ -146,15 +146,15 @@ class ServerConnectRoutes { }); event.onNext((event) async { - await playbackNotifier.next(); + await audioPlayer.skipToNext(); }); event.onPrevious((event) async { - await playbackNotifier.previous(); + await audioPlayer.skipToPrevious(); }); event.onJump((event) async { - await playbackNotifier.jumpTo(event.data); + await audioPlayer.jumpTo(event.data); }); event.onSeek((event) async { @@ -170,15 +170,15 @@ class ServerConnectRoutes { }); event.onAddTrack((event) async { - await playbackNotifier.addTrack(event.data); + await audioPlayerNotifier.addTrack(event.data); }); event.onRemoveTrack((event) async { - await playbackNotifier.removeTrack(event.data); + await audioPlayerNotifier.removeTrack(event.data); }); event.onReorder((event) async { - await playbackNotifier.moveTrack( + await audioPlayerNotifier.moveTrack( event.data.oldIndex, event.data.newIndex, ); diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index 679f58b1..f29aecf4 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -2,8 +2,8 @@ import 'package:dio/dio.dart' hide Response; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelf/shelf.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -12,7 +12,7 @@ import 'package:spotube/services/logger/logger.dart'; class ServerPlaybackRoutes { final Ref ref; UserPreferences get userPreferences => ref.read(userPreferencesProvider); - ProxyPlaylist get playlist => ref.read(proxyPlaylistProvider); + AudioPlayerState get playlist => ref.read(audioPlayerProvider); final Dio dio; ServerPlaybackRoutes(this.ref) : dio = Dio(); diff --git a/lib/provider/server/sourced_track.dart b/lib/provider/server/sourced_track.dart index 82c7ddcd..37c889b0 100644 --- a/lib/provider/server/sourced_track.dart +++ b/lib/provider/server/sourced_track.dart @@ -2,7 +2,7 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; final sourcedTrackProvider = @@ -12,10 +12,9 @@ final sourcedTrackProvider = } ref.listen( - proxyPlaylistProvider, + audioPlayerProvider.select((value) => value.tracks), (old, next) { - if (next.tracks.isEmpty || - next.tracks.none((element) => element.id == track.id)) { + if (next.isEmpty || next.none((element) => element.id == track.id)) { ref.invalidateSelf(); } }, diff --git a/lib/provider/proxy_playlist/skip_segments.dart b/lib/provider/skip_segments/skip_segments.dart similarity index 100% rename from lib/provider/proxy_playlist/skip_segments.dart rename to lib/provider/skip_segments/skip_segments.dart diff --git a/lib/provider/tray_manager/tray_menu.dart b/lib/provider/tray_manager/tray_menu.dart index 35aca4f5..42a3f948 100644 --- a/lib/provider/tray_manager/tray_menu.dart +++ b/lib/provider/tray_manager/tray_menu.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:media_kit/media_kit.dart' hide Track; import 'package:tray_manager/tray_manager.dart'; @@ -19,9 +19,9 @@ final audioPlayerPlaying = StreamProvider((ref) { }); final trayMenuProvider = Provider((ref) { - final playlistNotifier = ref.watch(proxyPlaylistProvider.notifier); + final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final isPlaybackPlaying = - ref.watch(proxyPlaylistProvider.select((s) => s.activeTrack != null)); + ref.watch(audioPlayerProvider.select((s) => s.activeTrack != null)); final isLoopOne = ref.watch(audioPlayerLoopMode).asData?.value == PlaylistMode.single; final isShuffled = ref.watch(audioPlayerShuffleMode).asData?.value ?? false; @@ -56,14 +56,14 @@ final trayMenuProvider = Provider((ref) { label: "Next", disabled: !isPlaybackPlaying, onClick: (menuItem) { - playlistNotifier.next(); + audioPlayer.skipToNext(); }, ), MenuItem( label: "Previous", disabled: !isPlaybackPlaying, onClick: (menuItem) { - playlistNotifier.previous(); + audioPlayer.skipToPrevious(); }, ), MenuItem.submenu( diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index a730c313..a421e7d0 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -6,10 +6,9 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/palette_provider.dart'; -import 'package:spotube/provider/proxy_playlist/player_listeners.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/utils/platform.dart'; @@ -115,7 +114,7 @@ class UserPreferencesNotifier extends Notifier { if (!sync) { ref.read(paletteProvider.notifier).state = null; } else { - ref.read(proxyPlaylistProvider.notifier).updatePalette(); + ref.read(audioPlayerStreamListenersProvider).updatePalette(); } } diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index f42d6c4b..63e43c4d 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -17,7 +17,7 @@ class AudioServices { static Future create( Ref ref, - ProxyPlaylistNotifier playback, + AudioPlayerNotifier playback, ) async { final mobile = kIsMobile || kIsMacOS || kIsLinux ? await AudioService.init( diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 3dbae18f..cdd16138 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -2,19 +2,19 @@ import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:media_kit/media_kit.dart' hide Track; class MobileAudioService extends BaseAudioHandler { AudioSession? session; - final ProxyPlaylistNotifier playlistNotifier; + final AudioPlayerNotifier audioPlayerNotifier; // ignore: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member - ProxyPlaylist get playlist => playlistNotifier.state; + AudioPlayerState get playlist => audioPlayerNotifier.state; - MobileAudioService(this.playlistNotifier) { + MobileAudioService(this.audioPlayerNotifier) { AudioSession.instance.then((s) { session = s; session?.configure(const AudioSessionConfiguration.music()); @@ -102,24 +102,24 @@ class MobileAudioService extends BaseAudioHandler { @override Future stop() async { - await playlistNotifier.stop(); + await audioPlayerNotifier.stop(); } @override Future skipToNext() async { - await playlistNotifier.next(); + await audioPlayer.skipToNext(); await super.skipToNext(); } @override Future skipToPrevious() async { - await playlistNotifier.previous(); + await audioPlayer.skipToPrevious(); await super.skipToPrevious(); } @override Future onTaskRemoved() async { - await playlistNotifier.stop(); + await audioPlayerNotifier.stop(); return super.onTaskRemoved(); } @@ -146,7 +146,7 @@ class MobileAudioService extends BaseAudioHandler { PlaylistMode.single => AudioServiceRepeatMode.one, _ => AudioServiceRepeatMode.none, }, - processingState: playlist.isFetching == true + processingState: audioPlayer.isBuffering ? AudioProcessingState.loading : AudioProcessingState.ready, ); diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index a3ee31e1..0b3113fc 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -5,18 +5,18 @@ import 'package:smtc_windows/smtc_windows.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/playback_state.dart'; class WindowsAudioService { final SMTCWindows smtc; final Ref ref; - final ProxyPlaylistNotifier playlistNotifier; + final AudioPlayerNotifier audioPlayerNotifier; final subscriptions = []; - WindowsAudioService(this.ref, this.playlistNotifier) + WindowsAudioService(this.ref, this.audioPlayerNotifier) : smtc = SMTCWindows(enabled: false) { smtc.setPlaybackStatus(PlaybackStatus.Stopped); final buttonStream = smtc.buttonPressStream.listen((event) { @@ -28,13 +28,13 @@ class WindowsAudioService { audioPlayer.pause(); break; case PressedButton.next: - playlistNotifier.next(); + audioPlayer.skipToNext(); break; case PressedButton.previous: - playlistNotifier.previous(); + audioPlayer.skipToPrevious(); break; case PressedButton.stop: - playlistNotifier.stop(); + audioPlayerNotifier.stop(); break; default: break; From 75173e5096209104763059f812961982bd3755e6 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 24 Jun 2024 21:01:09 +0600 Subject: [PATCH 148/261] refactor: use provider based is track loading implementation --- lib/collections/intents.dart | 6 +- lib/components/track_tile/track_tile.dart | 347 +++++++++--------- lib/modules/album/album_card.dart | 6 +- lib/modules/player/player_controls.dart | 22 +- lib/modules/player/player_overlay.dart | 9 +- lib/modules/player/sibling_tracks_sheet.dart | 11 +- lib/modules/playlist/playlist_card.dart | 5 +- lib/provider/audio_player/audio_player.dart | 5 - .../audio_player/querying_track_info.dart | 12 + 9 files changed, 216 insertions(+), 207 deletions(-) create mode 100644 lib/provider/audio_player/querying_track_info.dart diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index 1a44a846..ac0451ac 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -11,7 +11,7 @@ import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/search/search.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; @@ -96,8 +96,8 @@ class SeekIntent extends Intent { class SeekAction extends Action { @override invoke(intent) async { - final playlist = intent.ref.read(audioPlayerProvider.notifier); - if (playlist.isFetching()) { + final isFetchingActiveTrack = intent.ref.read(queryingTrackInfoProvider); + if (isFetchingActiveTrack) { DirectionalFocusAction().invoke( DirectionalFocusIntent( intent.forward ? TraversalDirection.right : TraversalDirection.left, diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index cdc18d9b..0e8d2cd0 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -17,7 +17,7 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/blacklist_provider.dart'; @@ -84,191 +84,190 @@ class TrackTile extends HookConsumerWidget { }, child: HoverBuilder( permanentState: isSelected || constrains.smAndDown ? true : null, - builder: (context, isHovering) { - return ListTile( - selected: isSelected, - onTap: () async { - try { - isLoading.value = true; - await onTap?.call(); - } finally { - if (context.mounted) { - isLoading.value = false; - } + builder: (context, isHovering) => ListTile( + selected: isSelected, + onTap: () async { + try { + isLoading.value = true; + await onTap?.call(); + } finally { + if (context.mounted) { + isLoading.value = false; } - }, - onLongPress: onLongPress, - enabled: !isBlackListed, - contentPadding: EdgeInsets.zero, - tileColor: - isBlackListed ? theme.colorScheme.errorContainer : null, - horizontalTitleGap: 12, - leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...?leadingActions, - if (index != null && onChanged == null && constrains.mdAndUp) - SizedBox( - width: 50, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '${(index ?? 0) + 1}', - maxLines: 1, - style: theme.textTheme.bodySmall, - textAlign: TextAlign.center, - ), + } + }, + onLongPress: onLongPress, + enabled: !isBlackListed, + contentPadding: EdgeInsets.zero, + tileColor: isBlackListed ? theme.colorScheme.errorContainer : null, + horizontalTitleGap: 12, + leadingAndTrailingTextStyle: theme.textTheme.bodyMedium, + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ...?leadingActions, + if (index != null && onChanged == null && constrains.mdAndUp) + SizedBox( + width: 50, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '${(index ?? 0) + 1}', + maxLines: 1, + style: theme.textTheme.bodySmall, + textAlign: TextAlign.center, ), - ) - else if (constrains.smAndDown) - const SizedBox(width: 16), - if (onChanged != null) - Checkbox( - value: selected, - onChanged: onChanged, ), - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: AspectRatio( - aspectRatio: 1, - child: UniversalImage( - path: (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - ), - fit: BoxFit.cover, - ), - ), - ), - Positioned.fill( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: isHovering - ? Colors.black.withOpacity(0.4) - : Colors.transparent, - ), - ), - ), - Positioned.fill( - child: Center( - child: IconTheme( - data: theme.iconTheme - .copyWith(size: 26, color: Colors.white), - child: Skeleton.ignore( - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: (isPlaying && - ref - .watch(audioPlayerProvider - .notifier) - .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), - ), - ), - ), - ), - ), - ], + ) + else if (constrains.smAndDown) + const SizedBox(width: 16), + if (onChanged != null) + Checkbox( + value: selected, + onChanged: onChanged, ), - ], - ), - title: Row( - children: [ - Expanded( - flex: 6, - child: switch (track) { - LocalTrack() => Text( - track.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - _ => LinkText( - track.name!, - "/track/${track.id}", - push: true, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - }, - ), - if (constrains.mdAndUp) ...[ - const SizedBox(width: 8), - Expanded( - flex: 4, - child: switch (track) { - LocalTrack() => Text( - track.album!.name!, - maxLines: 1, - overflow: TextOverflow.ellipsis, + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: AspectRatio( + aspectRatio: 1, + child: UniversalImage( + path: (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, ), - _ => Align( - alignment: Alignment.centerLeft, - child: LinkText( - track.album!.name!, - "/album/${track.album?.id}", - extra: track.album, - push: true, - overflow: TextOverflow.ellipsis, + fit: BoxFit.cover, + ), + ), + ), + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isHovering + ? Colors.black.withOpacity(0.4) + : Colors.transparent, + ), + ), + ), + Positioned.fill( + child: Center( + child: IconTheme( + data: theme.iconTheme + .copyWith(size: 26, color: Colors.white), + child: Skeleton.ignore( + child: Consumer( + builder: (context, ref, _) { + final isFetchingActiveTrack = + ref.watch(queryingTrackInfoProvider); + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: (isPlaying && isFetchingActiveTrack) || + 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), + ); + }, ), - ) - }, + ), + ), + ), ), ], - ], - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: track is LocalTrack - ? Text( - track.artists?.asString() ?? '', - ) - : ClipRect( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 40), - child: ArtistLink(artists: track.artists ?? []), - ), + ), + ], + ), + title: Row( + children: [ + Expanded( + flex: 6, + child: switch (track) { + LocalTrack() => Text( + track.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ + _ => LinkText( + track.name!, + "/track/${track.id}", + push: true, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + }, + ), + if (constrains.mdAndUp) ...[ const SizedBox(width: 8), - Text( - Duration(milliseconds: track.durationMs ?? 0) - .toHumanReadableString(padZero: false), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - TrackOptions( - track: track, - playlistId: playlistId, - userPlaylist: userPlaylist, - showMenuCbRef: showOptionCbRef, + Expanded( + flex: 4, + child: switch (track) { + LocalTrack() => Text( + track.album!.name!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + _ => Align( + alignment: Alignment.centerLeft, + child: LinkText( + track.album!.name!, + "/album/${track.album?.id}", + extra: track.album, + push: true, + overflow: TextOverflow.ellipsis, + ), + ) + }, ), ], - ), - ); - }, + ], + ), + subtitle: Align( + alignment: Alignment.centerLeft, + child: track is LocalTrack + ? Text( + track.artists?.asString() ?? '', + ) + : ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 40), + child: ArtistLink(artists: track.artists ?? []), + ), + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + Text( + Duration(milliseconds: track.durationMs ?? 0) + .toHumanReadableString(padZero: false), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + TrackOptions( + track: track, + playlistId: playlistId, + userPlaylist: userPlaylist, + showMenuCbRef: showOptionCbRef, + ), + ], + ), + ), ), ); }); diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index f9f70c66..de7aa5f8 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -10,6 +10,7 @@ import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/pages/album/album.dart'; +import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; @@ -35,6 +36,7 @@ class AlbumCard extends HookConsumerWidget { useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final historyNotifier = ref.read(playbackHistoryProvider.notifier); + final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); bool isPlaylistPlaying = useMemoized( () => playlist.containsCollection(album.id!), @@ -59,8 +61,8 @@ class AlbumCard extends HookConsumerWidget { ), margin: const EdgeInsets.symmetric(horizontal: 10), isPlaying: isPlaylistPlaying, - isLoading: (isPlaylistPlaying && playlistNotifier.isFetching()) || - updating.value, + isLoading: + (isPlaylistPlaying && isFetchingActiveTrack) || updating.value, title: album.name!, description: "${album.albumType?.formatted} • ${album.artists?.asString() ?? ""}", diff --git a/lib/modules/player/player_controls.dart b/lib/modules/player/player_controls.dart index c5ef82d6..25080b66 100644 --- a/lib/modules/player/player_controls.dart +++ b/lib/modules/player/player_controls.dart @@ -11,7 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/modules/player/use_progress.dart'; import 'package:spotube/models/logger.dart'; -import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; class PlayerControls extends HookConsumerWidget { @@ -43,8 +43,7 @@ class PlayerControls extends HookConsumerWidget { SeekIntent: SeekAction(), }, []); - ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; @@ -132,7 +131,7 @@ class PlayerControls extends HookConsumerWidget { // than total duration. Keeping it resolved value: progress.value.toDouble(), secondaryTrackValue: bufferProgress, - onChanged: playlistNotifier.isFetching() + onChanged: isFetchingActiveTrack ? null : (v) { progress.value = v; @@ -183,7 +182,7 @@ class PlayerControls extends HookConsumerWidget { : context.l10n.shuffle_playlist, icon: const Icon(SpotubeIcons.shuffle), style: shuffled ? activeButtonStyle : buttonStyle, - onPressed: playlistNotifier.isFetching() + onPressed: isFetchingActiveTrack ? null : () { if (shuffled) { @@ -198,7 +197,7 @@ class PlayerControls extends HookConsumerWidget { tooltip: context.l10n.previous_track, icon: const Icon(SpotubeIcons.skipBack), style: buttonStyle, - onPressed: playlistNotifier.isFetching() + onPressed: isFetchingActiveTrack ? null : audioPlayer.skipToPrevious, ), @@ -206,7 +205,7 @@ class PlayerControls extends HookConsumerWidget { tooltip: playing ? context.l10n.pause_playback : context.l10n.resume_playback, - icon: playlistNotifier.isFetching() + icon: isFetchingActiveTrack ? SizedBox( height: 20, width: 20, @@ -219,7 +218,7 @@ class PlayerControls extends HookConsumerWidget { playing ? SpotubeIcons.pause : SpotubeIcons.play, ), style: resumePauseStyle, - onPressed: playlistNotifier.isFetching() + onPressed: isFetchingActiveTrack ? null : Actions.handler( context, @@ -230,9 +229,8 @@ class PlayerControls extends HookConsumerWidget { tooltip: context.l10n.next_track, icon: const Icon(SpotubeIcons.skipForward), style: buttonStyle, - onPressed: playlistNotifier.isFetching() - ? null - : audioPlayer.skipToNext, + onPressed: + isFetchingActiveTrack ? null : audioPlayer.skipToNext, ), StreamBuilder( stream: audioPlayer.loopModeStream, @@ -253,7 +251,7 @@ class PlayerControls extends HookConsumerWidget { loopMode == PlaylistMode.loop ? activeButtonStyle : buttonStyle, - onPressed: playlistNotifier.isFetching() + onPressed: isFetchingActiveTrack ? null : () async { await audioPlayer.setLoopMode(loopMode); diff --git a/lib/modules/player/player_overlay.dart b/lib/modules/player/player_overlay.dart index c1b285ee..2322bcba 100644 --- a/lib/modules/player/player_overlay.dart +++ b/lib/modules/player/player_overlay.dart @@ -12,6 +12,7 @@ import 'package:spotube/collections/intents.dart'; import 'package:spotube/modules/player/use_progress.dart'; import 'package:spotube/modules/player/player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; class PlayerOverlay extends HookConsumerWidget { @@ -24,7 +25,7 @@ class PlayerOverlay extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final playlist = ref.watch(audioPlayerProvider); final canShow = playlist.activeTrack != null; @@ -127,14 +128,14 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipBack, color: textColor, ), - onPressed: playlistNotifier.isFetching() + onPressed: isFetchingActiveTrack ? null : audioPlayer.skipToPrevious, ), Consumer( builder: (context, ref, _) { return IconButton( - icon: playlistNotifier.isFetching() + icon: isFetchingActiveTrack ? const SizedBox( height: 20, width: 20, @@ -158,7 +159,7 @@ class PlayerOverlay extends HookConsumerWidget { SpotubeIcons.skipForward, color: textColor, ), - onPressed: playlistNotifier.isFetching() + onPressed: isFetchingActiveTrack ? null : audioPlayer.skipToNext, ), diff --git a/lib/modules/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart index 8592f1e3..ddc77b15 100644 --- a/lib/modules/player/sibling_tracks_sheet.dart +++ b/lib/modules/player/sibling_tracks_sheet.dart @@ -16,6 +16,7 @@ import 'package:spotube/extensions/duration.dart'; import 'package:spotube/hooks/utils/use_debounce.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -54,7 +55,7 @@ class SiblingTracksSheet extends HookConsumerWidget { Widget build(BuildContext context, ref) { final theme = Theme.of(context); final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final preferences = ref.watch(userPreferencesProvider); final isSearching = useState(false); @@ -130,7 +131,7 @@ class SiblingTracksSheet extends HookConsumerWidget { ]); final siblings = useMemoized( - () => playlistNotifier.isFetching() + () => isFetchingActiveTrack ? [ (activeTrack as SourcedTrack).sourceInfo, ...activeTrack.siblings, @@ -176,12 +177,12 @@ class SiblingTracksSheet extends HookConsumerWidget { Text(" • ${sourceInfo.artist}"), ], ), - enabled: !playlistNotifier.isFetching(), - selected: !playlistNotifier.isFetching() && + enabled: !isFetchingActiveTrack, + selected: !isFetchingActiveTrack && sourceInfo.id == (activeTrack as SourcedTrack).sourceInfo.id, selectedTileColor: theme.popupMenuTheme.color, onTap: () { - if (!playlistNotifier.isFetching() && + if (!isFetchingActiveTrack && sourceInfo.id != (activeTrack as SourcedTrack).sourceInfo.id) { activeTrackNotifier.swapSibling(sourceInfo); Navigator.of(context).pop(); diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index c4164701..4e81a254 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -7,6 +7,7 @@ import 'package:spotube/components/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/pages/playlist/playlist.dart'; +import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; @@ -24,6 +25,7 @@ class PlaylistCard extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlistQueue = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); + final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); final historyNotifier = ref.read(playbackHistoryProvider.notifier); final playing = @@ -65,8 +67,7 @@ class PlaylistCard extends HookConsumerWidget { placeholder: ImagePlaceholder.collection, ), isPlaying: isPlaylistPlaying, - isLoading: (isPlaylistPlaying && playlistNotifier.isFetching()) || - updating.value, + isLoading: (isPlaylistPlaying && isFetchingActiveTrack) || updating.value, isOwner: playlist.owner?.id == me.asData?.value.id && me.asData?.value.id != null, onTap: () { diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index 258e15d8..06081b19 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -299,11 +299,6 @@ class AudioPlayerNotifier extends Notifier { await audioPlayer.moveTrack(oldIndex, newIndex); } - bool isFetching() { - if (state.activeTrack == null) return false; - return ref.read(sourcedTrackProvider(state.activeTrack!)).isLoading; - } - Future stop() async { await audioPlayer.stop(); ref.read(discordProvider.notifier).clear(); diff --git a/lib/provider/audio_player/querying_track_info.dart b/lib/provider/audio_player/querying_track_info.dart new file mode 100644 index 00000000..4069523b --- /dev/null +++ b/lib/provider/audio_player/querying_track_info.dart @@ -0,0 +1,12 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/server/sourced_track.dart'; + +final queryingTrackInfoProvider = Provider((ref) { + final activeTrack = + ref.watch(audioPlayerProvider.select((s) => s.activeTrack)); + + if (activeTrack == null) return false; + + return ref.read(sourcedTrackProvider(activeTrack)).isLoading; +}); From a621a45f0bbbb4dfc4b342388996827fb64dfad8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 24 Jun 2024 21:43:09 +0600 Subject: [PATCH 149/261] chore: fix alternative track sources not showing up --- lib/modules/player/player_controls.dart | 57 +++++++++++-------- lib/modules/player/sibling_tracks_sheet.dart | 4 +- .../audio_player/querying_track_info.dart | 12 +++- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/lib/modules/player/player_controls.dart b/lib/modules/player/player_controls.dart index 25080b66..1b9d9f86 100644 --- a/lib/modules/player/player_controls.dart +++ b/lib/modules/player/player_controls.dart @@ -11,6 +11,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/modules/player/use_progress.dart'; import 'package:spotube/models/logger.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -232,32 +233,38 @@ class PlayerControls extends HookConsumerWidget { onPressed: isFetchingActiveTrack ? null : audioPlayer.skipToNext, ), - StreamBuilder( - stream: audioPlayer.loopModeStream, - builder: (context, snapshot) { - final loopMode = snapshot.data ?? PlaylistMode.none; - return IconButton( - tooltip: loopMode == PlaylistMode.single - ? context.l10n.loop_track - : loopMode == PlaylistMode.loop - ? context.l10n.repeat_playlist - : null, - icon: Icon( - loopMode == PlaylistMode.single - ? SpotubeIcons.repeatOne - : SpotubeIcons.repeat, - ), - style: loopMode == PlaylistMode.single || - loopMode == PlaylistMode.loop - ? activeButtonStyle - : buttonStyle, - onPressed: isFetchingActiveTrack - ? null - : () async { - await audioPlayer.setLoopMode(loopMode); + Consumer(builder: (context, ref, _) { + final loopMode = ref + .watch(audioPlayerProvider.select((s) => s.loopMode)); + + return IconButton( + tooltip: loopMode == PlaylistMode.single + ? context.l10n.loop_track + : loopMode == PlaylistMode.loop + ? context.l10n.repeat_playlist + : null, + icon: Icon( + loopMode == PlaylistMode.single + ? SpotubeIcons.repeatOne + : SpotubeIcons.repeat, + ), + style: loopMode == PlaylistMode.single || + loopMode == PlaylistMode.loop + ? activeButtonStyle + : buttonStyle, + onPressed: isFetchingActiveTrack + ? null + : () async { + await audioPlayer.setLoopMode( + switch (loopMode) { + PlaylistMode.loop => PlaylistMode.single, + PlaylistMode.single => PlaylistMode.none, + PlaylistMode.none => PlaylistMode.loop, }, - ); - }), + ); + }, + ); + }), ], ), const SizedBox(height: 5) diff --git a/lib/modules/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart index ddc77b15..092d631f 100644 --- a/lib/modules/player/sibling_tracks_sheet.dart +++ b/lib/modules/player/sibling_tracks_sheet.dart @@ -131,13 +131,13 @@ class SiblingTracksSheet extends HookConsumerWidget { ]); final siblings = useMemoized( - () => isFetchingActiveTrack + () => !isFetchingActiveTrack ? [ (activeTrack as SourcedTrack).sourceInfo, ...activeTrack.siblings, ] : [], - [activeTrack], + [activeTrack, isFetchingActiveTrack], ); final borderRadius = floating diff --git a/lib/provider/audio_player/querying_track_info.dart b/lib/provider/audio_player/querying_track_info.dart index 4069523b..f03efd9e 100644 --- a/lib/provider/audio_player/querying_track_info.dart +++ b/lib/provider/audio_player/querying_track_info.dart @@ -1,12 +1,20 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/server/sourced_track.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; final queryingTrackInfoProvider = Provider((ref) { + final media = audioPlayer.playlist.index == -1 + ? null + : audioPlayer.playlist.medias.elementAtOrNull(audioPlayer.playlist.index); + final audioPlayerActiveTrack = + media == null ? null : SpotubeMedia.fromMedia(media).track; + final activeTrack = - ref.watch(audioPlayerProvider.select((s) => s.activeTrack)); + ref.watch(audioPlayerProvider.select((s) => s.activeTrack)) ?? + audioPlayerActiveTrack; if (activeTrack == null) return false; - return ref.read(sourcedTrackProvider(activeTrack)).isLoading; + return ref.watch(sourcedTrackProvider(activeTrack)).isLoading; }); From 1b420e661beae8af86b4f639ce6c3569ffbc5f48 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 24 Jun 2024 22:26:44 +0600 Subject: [PATCH 150/261] chore: player skipping all tracks from cache --- lib/provider/audio_player/audio_player.dart | 10 +++++++--- lib/provider/audio_player/state.dart | 12 +++++++----- lib/provider/server/server.dart | 7 +++---- lib/services/audio_player/audio_player.dart | 16 +++++++++++++--- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index 06081b19..e5db78c0 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -72,14 +72,18 @@ class AudioPlayerNotifier extends Notifier { ], ); }); - } else { + } else if (medias.isNotEmpty) { await audioPlayer.openPlaylist( medias - .map((media) => Media( + .map( + (media) => SpotubeMedia.fromMedia( + Media( media.uri, extras: media.extras, httpHeaders: media.httpHeaders, - )) + ), + ), + ) .toList(), initialIndex: playlist.index, ); diff --git a/lib/provider/audio_player/state.dart b/lib/provider/audio_player/state.dart index 685ce112..3572e289 100644 --- a/lib/provider/audio_player/state.dart +++ b/lib/provider/audio_player/state.dart @@ -33,11 +33,13 @@ class AudioPlayerState { shuffled: json['shuffled'], playlist: Playlist( json['playlist']['medias'] - .map((media) => Media( - media['uri'], - extras: media['extras'], - httpHeaders: media['httpHeaders'], - )) + .map( + (media) => SpotubeMedia.fromMedia(Media( + media['uri'], + extras: media['extras'], + httpHeaders: media['httpHeaders'], + )), + ) .toList(), index: json['playlist']['index'], ), diff --git a/lib/provider/server/server.dart b/lib/provider/server/server.dart index 5232bb17..131f1ea4 100644 --- a/lib/provider/server/server.dart +++ b/lib/provider/server/server.dart @@ -5,15 +5,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shelf/shelf_io.dart'; import 'package:spotube/provider/server/pipeline.dart'; import 'package:spotube/provider/server/router.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/logger/logger.dart'; -int serverPort = 0; final serverProvider = FutureProvider( (ref) async { final pipeline = ref.watch(pipelineProvider); final router = ref.watch(serverRouterProvider); + final port = Random().nextInt(17500) + 5000; - final port = Random().nextInt(17000) + 1500; + SpotubeMedia.serverPort = port; final server = await serve( pipeline.addHandler(router.call), @@ -28,8 +29,6 @@ final serverProvider = FutureProvider( server.close(); }); - serverPort = port; - return (server: server, port: port); }, ); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 713d518b..0b62c068 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:media_kit/media_kit.dart' hide Track; -import 'package:spotube/provider/server/server.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:flutter/foundation.dart'; import 'package:spotify/spotify.dart' hide Playlist; @@ -20,9 +19,11 @@ part 'audio_player_impl.dart'; class SpotubeMedia extends mk.Media { final Track track; + static int serverPort = 0; + SpotubeMedia( this.track, { - Map? extras, + Map? extras, super.httpHeaders, }) : super( track is LocalTrack @@ -38,11 +39,20 @@ class SpotubeMedia extends mk.Media { }, ); + @override + String get uri => track is LocalTrack + ? (track as LocalTrack).path + : "http://${InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}"; + factory SpotubeMedia.fromMedia(mk.Media media) { final track = media.uri.startsWith("http") ? Track.fromJson(media.extras?["track"]) : LocalTrack.fromJson(media.extras?["track"]); - return SpotubeMedia(track); + return SpotubeMedia( + track, + extras: media.extras, + httpHeaders: media.httpHeaders, + ); } } From 6c5cab9899080450bbd077c206bb8dd41c8e9b5d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 25 Jun 2024 20:36:23 +0600 Subject: [PATCH 151/261] chore: fix use SpotubeMedia to avoid duplicate sourceTrackProvider instances --- lib/provider/audio_player/audio_player.dart | 8 +++++--- .../audio_player/audio_player_streams.dart | 10 ++++++---- .../audio_player/querying_track_info.dart | 18 +++++++++++------- lib/provider/audio_player/state.dart | 5 +++++ lib/provider/server/routes/playback.dart | 3 ++- lib/provider/server/sourced_track.dart | 5 +++-- lib/services/audio_player/audio_player.dart | 15 +++++++++++++++ 7 files changed, 47 insertions(+), 17 deletions(-) diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index e5db78c0..9dfc2c0a 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -86,6 +86,7 @@ class AudioPlayerNotifier extends Notifier { ) .toList(), initialIndex: playlist.index, + autoPlay: false, ); } } @@ -270,17 +271,18 @@ class AudioPlayerNotifier extends Notifier { int initialIndex = 0, bool autoPlay = false, }) async { - tracks = _blacklist.filter(tracks).toList() as List; + final medias = + (_blacklist.filter(tracks).toList() as List).asMediaList(); // Giving the initial track a boost so MediaKit won't skip // because of timeout - final intendedActiveTrack = tracks.elementAt(initialIndex); + final intendedActiveTrack = medias.elementAt(initialIndex); if (intendedActiveTrack is! LocalTrack) { await ref.read(sourcedTrackProvider(intendedActiveTrack).future); } await audioPlayer.openPlaylist( - tracks.asMediaList(), + medias, initialIndex: initialIndex, autoPlay: autoPlay, ); diff --git a/lib/provider/audio_player/audio_player_streams.dart b/lib/provider/audio_player/audio_player_streams.dart index d5473dd5..42944075 100644 --- a/lib/provider/audio_player/audio_player_streams.dart +++ b/lib/provider/audio_player/audio_player_streams.dart @@ -128,15 +128,17 @@ class AudioPlayerStreamListeners { audioPlayerState.tracks.length - 1) { return; } - final nextTrack = audioPlayerState.tracks - .elementAt(audioPlayerState.playlist.index + 1); + final nextTrack = SpotubeMedia.fromMedia(audioPlayerState.playlist.medias + .elementAt(audioPlayerState.playlist.index + 1)); - if (lastTrack == nextTrack.id || nextTrack is LocalTrack) return; + if (lastTrack == nextTrack.track.id || nextTrack.track is LocalTrack) { + return; + } try { await ref.read(sourcedTrackProvider(nextTrack).future); } finally { - lastTrack = nextTrack.id!; + lastTrack = nextTrack.track.id!; } }); } diff --git a/lib/provider/audio_player/querying_track_info.dart b/lib/provider/audio_player/querying_track_info.dart index f03efd9e..55590d48 100644 --- a/lib/provider/audio_player/querying_track_info.dart +++ b/lib/provider/audio_player/querying_track_info.dart @@ -4,17 +4,21 @@ import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; final queryingTrackInfoProvider = Provider((ref) { - final media = audioPlayer.playlist.index == -1 + final media = audioPlayer.playlist.index == -1 || + audioPlayer.playlist.medias.isEmpty ? null : audioPlayer.playlist.medias.elementAtOrNull(audioPlayer.playlist.index); final audioPlayerActiveTrack = - media == null ? null : SpotubeMedia.fromMedia(media).track; + media == null ? null : SpotubeMedia.fromMedia(media); - final activeTrack = - ref.watch(audioPlayerProvider.select((s) => s.activeTrack)) ?? - audioPlayerActiveTrack; + final activeMedia = ref.watch(audioPlayerProvider.select( + (s) => s.activeMedia == null + ? null + : SpotubeMedia.fromMedia(s.activeMedia!), + )) ?? + audioPlayerActiveTrack; - if (activeTrack == null) return false; + if (activeMedia == null) return false; - return ref.watch(sourcedTrackProvider(activeTrack)).isLoading; + return ref.watch(sourcedTrackProvider(activeMedia)).isLoading; }); diff --git a/lib/provider/audio_player/state.dart b/lib/provider/audio_player/state.dart index 3572e289..387c2e30 100644 --- a/lib/provider/audio_player/state.dart +++ b/lib/provider/audio_player/state.dart @@ -88,6 +88,11 @@ class AudioPlayerState { return tracks.elementAtOrNull(playlist.index); } + Media? get activeMedia { + if (playlist.index == -1 || playlist.medias.isEmpty) return null; + return playlist.medias.elementAt(playlist.index); + } + bool containsTrack(Track track) { return tracks.any((t) => t.id == track.id); } diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index f29aecf4..aa380d01 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -7,6 +7,7 @@ import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/logger/logger.dart'; class ServerPlaybackRoutes { @@ -25,7 +26,7 @@ class ServerPlaybackRoutes { final activeSourcedTrack = ref.read(activeSourcedTrackProvider); final sourcedTrack = activeSourcedTrack?.id == track.id ? activeSourcedTrack - : await ref.read(sourcedTrackProvider(track).future); + : await ref.read(sourcedTrackProvider(SpotubeMedia(track)).future); ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack); diff --git a/lib/provider/server/sourced_track.dart b/lib/provider/server/sourced_track.dart index 37c889b0..53a04023 100644 --- a/lib/provider/server/sourced_track.dart +++ b/lib/provider/server/sourced_track.dart @@ -1,12 +1,13 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; final sourcedTrackProvider = - FutureProvider.family((ref, track) async { + FutureProvider.family((ref, media) async { + final track = media?.track; if (track == null || track is LocalTrack) { return null; } diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 0b62c068..bb1a6203 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -54,6 +54,21 @@ class SpotubeMedia extends mk.Media { httpHeaders: media.httpHeaders, ); } + + @override + operator ==(Object other) { + if (other is! SpotubeMedia) return false; + + final isLocal = track is LocalTrack && other.track is LocalTrack; + return isLocal + ? (other.track as LocalTrack).path == (track as LocalTrack).path + : other.track.id == track.id; + } + + @override + int get hashCode => track is LocalTrack + ? (track as LocalTrack).path.hashCode + : track.id.hashCode; } abstract class AudioPlayerInterface { From 44418868ad10e4f1043777973f70e1d2745c7fcc Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 25 Jun 2024 20:38:40 +0600 Subject: [PATCH 152/261] chore: fix volume not being set after launch --- lib/provider/volume_provider.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/provider/volume_provider.dart b/lib/provider/volume_provider.dart index ddd38fd9..64bcfe1a 100644 --- a/lib/provider/volume_provider.dart +++ b/lib/provider/volume_provider.dart @@ -9,6 +9,7 @@ class VolumeProvider extends Notifier { @override build() { + audioPlayer.setVolume(KVStoreService.volume); return KVStoreService.volume; } From 08ac29c97936e4015ef23ee4b7890ab0cfe31766 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 29 Jun 2024 17:05:06 +0600 Subject: [PATCH 153/261] refactor(stats): migrate stats to use drift db --- build.yaml | 7 + lib/collections/fake.dart | 34 ++ .../sections/body/track_view_body.dart | 2 +- .../sections/body/track_view_options.dart | 2 +- .../sections/header/header_actions.dart | 2 +- .../sections/header/header_buttons.dart | 2 +- lib/models/database/database.dart | 6 +- lib/models/database/database.g.dart | 441 ++++++++++++++++++ lib/models/database/tables/history.dart | 25 + lib/modules/album/album_card.dart | 2 +- lib/modules/home/sections/recent.dart | 35 +- lib/modules/playlist/playlist_card.dart | 2 +- lib/modules/stats/summary/summary.dart | 156 ++++--- lib/modules/stats/top/albums.dart | 30 +- lib/modules/stats/top/artists.dart | 8 +- lib/modules/stats/top/tracks.dart | 8 +- lib/pages/lyrics/synced_lyrics.dart | 6 +- lib/pages/stats/albums/albums.dart | 12 +- lib/pages/stats/artists/artists.dart | 8 +- lib/pages/stats/fees/fees.dart | 8 +- lib/pages/stats/minutes/minutes.dart | 8 +- lib/pages/stats/playlists/playlists.dart | 10 +- lib/pages/stats/streams/streams.dart | 8 +- .../audio_player/audio_player_streams.dart | 4 +- lib/provider/history/history.dart | 163 ++----- lib/provider/history/recent.dart | 89 ++-- lib/provider/history/summary.dart | 237 ++++++++-- lib/provider/history/top.dart | 273 +++++++---- lib/provider/server/routes/connect.dart | 4 +- 29 files changed, 1169 insertions(+), 423 deletions(-) create mode 100644 lib/models/database/tables/history.dart diff --git a/build.yaml b/build.yaml index d83d6a20..8dbfe45d 100644 --- a/build.yaml +++ b/build.yaml @@ -8,3 +8,10 @@ targets: options: any_map: true explicit_to_json: true + drift_dev: + options: + sql: + dialect: sqlite + options: + modules: + - json1 diff --git a/lib/collections/fake.dart b/lib/collections/fake.dart index 7391d3a0..31f97e0c 100644 --- a/lib/collections/fake.dart +++ b/lib/collections/fake.dart @@ -1,6 +1,8 @@ import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/spotify/home_feed.dart'; import 'package:spotube/models/spotify_friends.dart'; +import 'package:spotube/provider/history/summary.dart'; abstract class FakeData { static final Image image = Image() @@ -222,4 +224,36 @@ abstract class FakeData { ) ], ); + + static const historySummary = PlaybackHistorySummary( + albums: 1, + artists: 1, + duration: Duration(seconds: 1), + playlists: 1, + tracks: 1, + fees: 1, + ); + + static final historyRecentlyPlayedPlaylist = HistoryTableData( + id: 0, + type: HistoryEntryType.track, + createdAt: DateTime.now(), + itemId: "1", + data: playlist.toJson(), + ); + + static final historyRecentlyPlayedAlbum = HistoryTableData( + id: 0, + type: HistoryEntryType.track, + createdAt: DateTime.now(), + itemId: "1", + data: album.toJson(), + ); + + static final historyRecentlyPlayedItems = List.generate( + 10, + (index) => index % 2 == 0 + ? historyRecentlyPlayedPlaylist + : historyRecentlyPlayedAlbum, + ); } diff --git a/lib/components/tracks_view/sections/body/track_view_body.dart b/lib/components/tracks_view/sections/body/track_view_body.dart index a6089cc3..df841b8d 100644 --- a/lib/components/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/tracks_view/sections/body/track_view_body.dart @@ -29,7 +29,7 @@ class TrackViewBodySection extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlist = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final props = InheritedTrackView.of(context); final trackViewState = ref.watch(trackViewProvider(props.tracks)); diff --git a/lib/components/tracks_view/sections/body/track_view_options.dart b/lib/components/tracks_view/sections/body/track_view_options.dart index 98ddca25..23198aec 100644 --- a/lib/components/tracks_view/sections/body/track_view_options.dart +++ b/lib/components/tracks_view/sections/body/track_view_options.dart @@ -25,7 +25,7 @@ class TrackViewBodyOptions extends HookConsumerWidget { ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final audioSource = ref.watch(userPreferencesProvider.select((s) => s.audioSource)); diff --git a/lib/components/tracks_view/sections/header/header_actions.dart b/lib/components/tracks_view/sections/header/header_actions.dart index 6769ed52..94f0baa2 100644 --- a/lib/components/tracks_view/sections/header/header_actions.dart +++ b/lib/components/tracks_view/sections/header/header_actions.dart @@ -22,7 +22,7 @@ class TrackViewHeaderActions extends HookConsumerWidget { final playlist = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final isActive = playlist.collections.contains(props.collectionId); diff --git a/lib/components/tracks_view/sections/header/header_buttons.dart b/lib/components/tracks_view/sections/header/header_buttons.dart index aabca20f..e9fbf6bb 100644 --- a/lib/components/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/tracks_view/sections/header/header_buttons.dart @@ -30,7 +30,7 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final props = InheritedTrackView.of(context); final playlist = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.watch(playbackHistoryProvider.notifier); + final historyNotifier = ref.watch(playbackHistoryActionsProvider); final isActive = playlist.collections.contains(props.collectionId); diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 98dc22dc..9b47aaab 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -5,10 +5,10 @@ import 'dart:io'; import 'package:drift/drift.dart'; import 'package:encrypt/encrypt.dart'; -import 'package:media_kit/media_kit.dart'; +import 'package:media_kit/media_kit.dart' hide Track; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; -import 'package:spotify/spotify.dart'; +import 'package:spotify/spotify.dart' hide Playlist; import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -27,6 +27,7 @@ part 'tables/scrobbler.dart'; part 'tables/skip_segment.dart'; part 'tables/source_match.dart'; part 'tables/audio_player_state.dart'; +part 'tables/history.dart'; part 'typeconverters/color.dart'; part 'typeconverters/locale.dart'; @@ -45,6 +46,7 @@ part 'typeconverters/map.dart'; AudioPlayerStateTable, PlaylistTable, PlaylistMediaTable, + HistoryTable, ], ) class AppDatabase extends _$AppDatabase { diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index 37cc930c..bb7d2fb6 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -3414,6 +3414,301 @@ class PlaylistMediaTableCompanion } } +class $HistoryTableTable extends HistoryTable + with TableInfo<$HistoryTableTable, HistoryTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $HistoryTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime); + static const VerificationMeta _typeMeta = const VerificationMeta('type'); + @override + late final GeneratedColumnWithTypeConverter type = + GeneratedColumn('type', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter($HistoryTableTable.$convertertype); + static const VerificationMeta _itemIdMeta = const VerificationMeta('itemId'); + @override + late final GeneratedColumn itemId = GeneratedColumn( + 'item_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _dataMeta = const VerificationMeta('data'); + @override + late final GeneratedColumnWithTypeConverter, String> + data = GeneratedColumn('data', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter>( + $HistoryTableTable.$converterdata); + @override + List get $columns => [id, createdAt, type, itemId, data]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'history_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + context.handle(_typeMeta, const VerificationResult.success()); + if (data.containsKey('item_id')) { + context.handle(_itemIdMeta, + itemId.isAcceptableOrUnknown(data['item_id']!, _itemIdMeta)); + } else if (isInserting) { + context.missing(_itemIdMeta); + } + context.handle(_dataMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {id}; + @override + HistoryTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return HistoryTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + type: $HistoryTableTable.$convertertype.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}type'])!), + itemId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}item_id'])!, + data: $HistoryTableTable.$converterdata.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}data'])!), + ); + } + + @override + $HistoryTableTable createAlias(String alias) { + return $HistoryTableTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 $convertertype = + const EnumNameConverter(HistoryEntryType.values); + static TypeConverter, String> $converterdata = + const MapTypeConverter(); +} + +class HistoryTableData extends DataClass + implements Insertable { + final int id; + final DateTime createdAt; + final HistoryEntryType type; + final String itemId; + final Map data; + const HistoryTableData( + {required this.id, + required this.createdAt, + required this.type, + required this.itemId, + required this.data}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['created_at'] = Variable(createdAt); + { + map['type'] = + Variable($HistoryTableTable.$convertertype.toSql(type)); + } + map['item_id'] = Variable(itemId); + { + map['data'] = + Variable($HistoryTableTable.$converterdata.toSql(data)); + } + return map; + } + + HistoryTableCompanion toCompanion(bool nullToAbsent) { + return HistoryTableCompanion( + id: Value(id), + createdAt: Value(createdAt), + type: Value(type), + itemId: Value(itemId), + data: Value(data), + ); + } + + factory HistoryTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return HistoryTableData( + id: serializer.fromJson(json['id']), + createdAt: serializer.fromJson(json['createdAt']), + type: $HistoryTableTable.$convertertype + .fromJson(serializer.fromJson(json['type'])), + itemId: serializer.fromJson(json['itemId']), + data: serializer.fromJson>(json['data']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'createdAt': serializer.toJson(createdAt), + 'type': serializer + .toJson($HistoryTableTable.$convertertype.toJson(type)), + 'itemId': serializer.toJson(itemId), + 'data': serializer.toJson>(data), + }; + } + + HistoryTableData copyWith( + {int? id, + DateTime? createdAt, + HistoryEntryType? type, + String? itemId, + Map? data}) => + HistoryTableData( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + type: type ?? this.type, + itemId: itemId ?? this.itemId, + data: data ?? this.data, + ); + @override + String toString() { + return (StringBuffer('HistoryTableData(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('type: $type, ') + ..write('itemId: $itemId, ') + ..write('data: $data') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, createdAt, type, itemId, data); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is HistoryTableData && + other.id == this.id && + other.createdAt == this.createdAt && + other.type == this.type && + other.itemId == this.itemId && + other.data == this.data); +} + +class HistoryTableCompanion extends UpdateCompanion { + final Value id; + final Value createdAt; + final Value type; + final Value itemId; + final Value> data; + const HistoryTableCompanion({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + this.type = const Value.absent(), + this.itemId = const Value.absent(), + this.data = const Value.absent(), + }); + HistoryTableCompanion.insert({ + this.id = const Value.absent(), + this.createdAt = const Value.absent(), + required HistoryEntryType type, + required String itemId, + required Map data, + }) : type = Value(type), + itemId = Value(itemId), + data = Value(data); + static Insertable custom({ + Expression? id, + Expression? createdAt, + Expression? type, + Expression? itemId, + Expression? data, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (createdAt != null) 'created_at': createdAt, + if (type != null) 'type': type, + if (itemId != null) 'item_id': itemId, + if (data != null) 'data': data, + }); + } + + HistoryTableCompanion copyWith( + {Value? id, + Value? createdAt, + Value? type, + Value? itemId, + Value>? data}) { + return HistoryTableCompanion( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + type: type ?? this.type, + itemId: itemId ?? this.itemId, + data: data ?? this.data, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (type.present) { + map['type'] = + Variable($HistoryTableTable.$convertertype.toSql(type.value)); + } + if (itemId.present) { + map['item_id'] = Variable(itemId.value); + } + if (data.present) { + map['data'] = + Variable($HistoryTableTable.$converterdata.toSql(data.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('HistoryTableCompanion(') + ..write('id: $id, ') + ..write('createdAt: $createdAt, ') + ..write('type: $type, ') + ..write('itemId: $itemId, ') + ..write('data: $data') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); _$AppDatabaseManager get managers => _$AppDatabaseManager(this); @@ -3432,6 +3727,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $PlaylistTableTable playlistTable = $PlaylistTableTable(this); late final $PlaylistMediaTableTable playlistMediaTable = $PlaylistMediaTableTable(this); + late final $HistoryTableTable historyTable = $HistoryTableTable(this); late final Index uniqueBlacklist = Index('unique_blacklist', 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); late final Index uniqTrackMatch = Index('uniq_track_match', @@ -3450,6 +3746,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { audioPlayerStateTable, playlistTable, playlistMediaTable, + historyTable, uniqueBlacklist, uniqTrackMatch ]; @@ -5053,6 +5350,148 @@ class $$PlaylistMediaTableTableOrderingComposer } } +typedef $$HistoryTableTableInsertCompanionBuilder = HistoryTableCompanion + Function({ + Value id, + Value createdAt, + required HistoryEntryType type, + required String itemId, + required Map data, +}); +typedef $$HistoryTableTableUpdateCompanionBuilder = HistoryTableCompanion + Function({ + Value id, + Value createdAt, + Value type, + Value itemId, + Value> data, +}); + +class $$HistoryTableTableTableManager extends RootTableManager< + _$AppDatabase, + $HistoryTableTable, + HistoryTableData, + $$HistoryTableTableFilterComposer, + $$HistoryTableTableOrderingComposer, + $$HistoryTableTableProcessedTableManager, + $$HistoryTableTableInsertCompanionBuilder, + $$HistoryTableTableUpdateCompanionBuilder> { + $$HistoryTableTableTableManager(_$AppDatabase db, $HistoryTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$HistoryTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$HistoryTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$HistoryTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + Value type = const Value.absent(), + Value itemId = const Value.absent(), + Value> data = const Value.absent(), + }) => + HistoryTableCompanion( + id: id, + createdAt: createdAt, + type: type, + itemId: itemId, + data: data, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + Value createdAt = const Value.absent(), + required HistoryEntryType type, + required String itemId, + required Map data, + }) => + HistoryTableCompanion.insert( + id: id, + createdAt: createdAt, + type: type, + itemId: itemId, + data: data, + ), + )); +} + +class $$HistoryTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $HistoryTableTable, + HistoryTableData, + $$HistoryTableTableFilterComposer, + $$HistoryTableTableOrderingComposer, + $$HistoryTableTableProcessedTableManager, + $$HistoryTableTableInsertCompanionBuilder, + $$HistoryTableTableUpdateCompanionBuilder> { + $$HistoryTableTableProcessedTableManager(super.$state); +} + +class $$HistoryTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $HistoryTableTable> { + $$HistoryTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get type => $state.composableBuilder( + column: $state.table.type, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get itemId => $state.composableBuilder( + column: $state.table.itemId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters, Map, + String> + get data => $state.composableBuilder( + column: $state.table.data, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); +} + +class $$HistoryTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $HistoryTableTable> { + $$HistoryTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get createdAt => $state.composableBuilder( + column: $state.table.createdAt, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get type => $state.composableBuilder( + column: $state.table.type, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get itemId => $state.composableBuilder( + column: $state.table.itemId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get data => $state.composableBuilder( + column: $state.table.data, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + class _$AppDatabaseManager { final _$AppDatabase _db; _$AppDatabaseManager(this._db); @@ -5074,4 +5513,6 @@ class _$AppDatabaseManager { $$PlaylistTableTableTableManager(_db, _db.playlistTable); $$PlaylistMediaTableTableTableManager get playlistMediaTable => $$PlaylistMediaTableTableTableManager(_db, _db.playlistMediaTable); + $$HistoryTableTableTableManager get historyTable => + $$HistoryTableTableTableManager(_db, _db.historyTable); } diff --git a/lib/models/database/tables/history.dart b/lib/models/database/tables/history.dart new file mode 100644 index 00000000..23c16f17 --- /dev/null +++ b/lib/models/database/tables/history.dart @@ -0,0 +1,25 @@ +part of '../database.dart'; + +enum HistoryEntryType { + playlist, + album, + track, +} + +class HistoryTable extends Table { + IntColumn get id => integer().autoIncrement()(); + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + TextColumn get type => textEnum()(); + TextColumn get itemId => text()(); + TextColumn get data => + text().map(const MapTypeConverter())(); +} + +extension HistoryItemParseExtension on HistoryTableData { + PlaylistSimple? get playlist => + type == HistoryEntryType.playlist ? PlaylistSimple.fromJson(data) : null; + AlbumSimple? get album => + type == HistoryEntryType.album ? AlbumSimple.fromJson(data) : null; + Track? get track => + type == HistoryEntryType.track ? Track.fromJson(data) : null; +} diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index de7aa5f8..dd914fad 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -35,7 +35,7 @@ class AlbumCard extends HookConsumerWidget { final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; final playlistNotifier = ref.watch(audioPlayerProvider.notifier); - final historyNotifier = ref.read(playbackHistoryProvider.notifier); + final historyNotifier = ref.read(playbackHistoryActionsProvider); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); bool isPlaylistPlaying = useMemoized( diff --git a/lib/modules/home/sections/recent.dart b/lib/modules/home/sections/recent.dart index 5be2fcc2..b26c0e16 100644 --- a/lib/modules/home/sections/recent.dart +++ b/lib/modules/home/sections/recent.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/history/recent.dart'; -import 'package:spotube/provider/history/state.dart'; class HomeRecentlyPlayedSection extends HookConsumerWidget { const HomeRecentlyPlayedSection({super.key}); @@ -10,23 +12,28 @@ class HomeRecentlyPlayedSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final history = ref.watch(recentlyPlayedItems); + final historyData = + history.asData?.value ?? FakeData.historyRecentlyPlayedItems; - if (history.isEmpty) { + if (history.asData?.value.isEmpty == true) { return const SizedBox(); } - return HorizontalPlaybuttonCardView( - title: const Text('Recently Played'), - items: [ - for (final item in history) - if (item is PlaybackHistoryPlaylist) - item.playlist - else if (item is PlaybackHistoryAlbum) - item.album - ], - hasNextPage: false, - isLoadingNextPage: false, - onFetchMore: () {}, + return Skeletonizer( + enabled: history.isLoading, + child: HorizontalPlaybuttonCardView( + title: const Text('Recently Played'), + items: [ + for (final item in historyData) + if (item.playlist != null) + item.playlist + else if (item.album != null) + item.album + ], + hasNextPage: false, + isLoadingNextPage: false, + onFetchMore: () {}, + ), ); } } diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index 4e81a254..d6ea2a46 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -26,7 +26,7 @@ class PlaylistCard extends HookConsumerWidget { final playlistQueue = ref.watch(audioPlayerProvider); final playlistNotifier = ref.watch(audioPlayerProvider.notifier); final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider); - final historyNotifier = ref.read(playbackHistoryProvider.notifier); + final historyNotifier = ref.read(playbackHistoryActionsProvider); final playing = useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; diff --git a/lib/modules/stats/summary/summary.dart b/lib/modules/stats/summary/summary.dart index 0b6c6040..ef8aa1b0 100644 --- a/lib/modules/stats/summary/summary.dart +++ b/lib/modules/stats/summary/summary.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; +import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/summary/summary_card.dart'; import 'package:spotube/extensions/constrains.dart'; @@ -18,83 +20,87 @@ class StatsPageSummarySection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final summary = ref.watch(playbackHistorySummaryProvider); + final summaryData = summary.asData?.value ?? FakeData.historySummary; - return SliverPadding( - padding: const EdgeInsets.all(10), - sliver: SliverLayoutBuilder(builder: (context, constrains) { - return SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: constrains.isXs - ? 2 - : constrains.smAndDown - ? 3 - : constrains.mdAndDown - ? 4 - : constrains.lgAndDown - ? 5 - : 6, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: constrains.isXs ? 1.3 : 1.5, - ), - delegate: SliverChildListDelegate([ - SummaryCard( - title: summary.duration.inMinutes.toDouble(), - unit: "minutes", - description: 'Listened to music', - color: Colors.purple, - onTap: () { - ServiceUtils.pushNamed(context, StatsMinutesPage.name); - }, + return Skeletonizer.sliver( + enabled: summary.isLoading, + child: SliverPadding( + padding: const EdgeInsets.all(10), + sliver: SliverLayoutBuilder(builder: (context, constrains) { + return SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: constrains.isXs + ? 2 + : constrains.smAndDown + ? 3 + : constrains.mdAndDown + ? 4 + : constrains.lgAndDown + ? 5 + : 6, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: constrains.isXs ? 1.3 : 1.5, ), - SummaryCard( - title: summary.tracks.toDouble(), - unit: "songs", - description: 'Streamed overall', - color: Colors.lightBlue, - onTap: () { - ServiceUtils.pushNamed(context, StatsStreamsPage.name); - }, - ), - SummaryCard.unformatted( - title: usdFormatter.format(summary.fees.toDouble()), - unit: "", - description: 'Owed to artists\nthis month', - color: Colors.green, - onTap: () { - ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); - }, - ), - SummaryCard( - title: summary.artists.toDouble(), - unit: "artist's", - description: 'Music reached you', - color: Colors.yellow, - onTap: () { - ServiceUtils.pushNamed(context, StatsArtistsPage.name); - }, - ), - SummaryCard( - title: summary.albums.toDouble(), - unit: "full albums", - description: 'Got your love', - color: Colors.pink, - onTap: () { - ServiceUtils.pushNamed(context, StatsAlbumsPage.name); - }, - ), - SummaryCard( - title: summary.playlists.toDouble(), - unit: "playlists", - description: 'Were on repeat', - color: Colors.teal, - onTap: () { - ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); - }, - ), - ]), - ); - }), + delegate: SliverChildListDelegate([ + SummaryCard( + title: summaryData.duration.inMinutes.toDouble(), + unit: "minutes", + description: 'Listened to music', + color: Colors.purple, + onTap: () { + ServiceUtils.pushNamed(context, StatsMinutesPage.name); + }, + ), + SummaryCard( + title: summaryData.tracks.toDouble(), + unit: "songs", + description: 'Streamed overall', + color: Colors.lightBlue, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamsPage.name); + }, + ), + SummaryCard.unformatted( + title: usdFormatter.format(summaryData.fees.toDouble()), + unit: "", + description: 'Owed to artists\nthis month', + color: Colors.green, + onTap: () { + ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); + }, + ), + SummaryCard( + title: summaryData.artists.toDouble(), + unit: "artist's", + description: 'Music reached you', + color: Colors.yellow, + onTap: () { + ServiceUtils.pushNamed(context, StatsArtistsPage.name); + }, + ), + SummaryCard( + title: summaryData.albums.toDouble(), + unit: "full albums", + description: 'Got your love', + color: Colors.pink, + onTap: () { + ServiceUtils.pushNamed(context, StatsAlbumsPage.name); + }, + ), + SummaryCard( + title: summaryData.playlists.toDouble(), + unit: "playlists", + description: 'Were on repeat', + color: Colors.teal, + onTap: () { + ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); + }, + ), + ]), + ); + }), + ), ); } } diff --git a/lib/modules/stats/top/albums.dart b/lib/modules/stats/top/albums.dart index 808a58a4..bcaa75c5 100644 --- a/lib/modules/stats/top/albums.dart +++ b/lib/modules/stats/top/albums.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/provider/history/top.dart'; @@ -11,19 +12,24 @@ class TopAlbums extends HookConsumerWidget { Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final albums = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.albums)); + .select((value) => value.whenData((s) => s.albums))); - return SliverList.builder( - itemCount: albums.length, - itemBuilder: (context, index) { - final album = albums[index]; - return StatsAlbumItem( - album: album.album, - info: Text( - "${compactNumberFormatter.format(album.count)} plays", - ), - ); - }, + final albumsData = albums.asData?.value ?? []; + + return Skeletonizer( + enabled: albums.isLoading, + child: SliverList.builder( + itemCount: albumsData.length, + itemBuilder: (context, index) { + final album = albumsData[index]; + return StatsAlbumItem( + album: album.album, + info: Text( + "${compactNumberFormatter.format(album.count)} plays", + ), + ); + }, + ), ); } } diff --git a/lib/modules/stats/top/artists.dart b/lib/modules/stats/top/artists.dart index 24e97601..094353f2 100644 --- a/lib/modules/stats/top/artists.dart +++ b/lib/modules/stats/top/artists.dart @@ -11,12 +11,14 @@ class TopArtists extends HookConsumerWidget { Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final artists = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.artists)); + .select((value) => value.whenData((s) => s.artists))); + + final artistsData = artists.asData?.value ?? []; return SliverList.builder( - itemCount: artists.length, + itemCount: artistsData.length, itemBuilder: (context, index) { - final artist = artists[index]; + final artist = artistsData[index]; return StatsArtistItem( artist: artist.artist, info: Text("${compactNumberFormatter.format(artist.count)} plays"), diff --git a/lib/modules/stats/top/tracks.dart b/lib/modules/stats/top/tracks.dart index ee37af3b..8bffa800 100644 --- a/lib/modules/stats/top/tracks.dart +++ b/lib/modules/stats/top/tracks.dart @@ -12,13 +12,15 @@ class TopTracks extends HookConsumerWidget { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); final tracks = ref.watch( playbackHistoryTopProvider(historyDuration) - .select((value) => value.tracks), + .select((value) => value.whenData((s) => s.tracks)), ); + final tracksData = tracks.asData?.value ?? []; + return SliverList.builder( - itemCount: tracks.length, + itemCount: tracksData.length, itemBuilder: (context, index) { - final track = tracks[index]; + final track = tracksData[index]; return StatsTrackItem( track: track.track, info: Text( diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 3294bab5..21796725 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -139,14 +139,12 @@ class SyncedLyrics extends HookConsumerWidget { textAlign: TextAlign.center, child: InkWell( onTap: () async { - final duration = - await audioPlayer.duration ?? - Duration.zero; final time = Duration( seconds: lyricSlice.time.inSeconds - delay, ); - if (time > duration || time.isNegative) { + if (time > audioPlayer.duration || + time.isNegative) { return; } audioPlayer.seek(time); diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index 868f068a..a13e500b 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -12,10 +12,10 @@ class StatsAlbumsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final albums = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.albums), - ); + final albums = ref.watch(playbackHistoryTopProvider(HistoryDuration.allTime) + .select((value) => value.whenData((s) => s.albums))); + + final albumsData = albums.asData?.value ?? []; return Scaffold( appBar: const PageWindowTitleBar( @@ -24,9 +24,9 @@ class StatsAlbumsPage extends HookConsumerWidget { title: Text("Albums"), ), body: ListView.builder( - itemCount: albums.length, + itemCount: albumsData.length, itemBuilder: (context, index) { - final album = albums[index]; + final album = albumsData[index]; return StatsAlbumItem( album: album.album, info: Text("${compactNumberFormatter.format(album.count)} plays"), diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart index b3f8c240..9ebdbe5d 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -14,9 +14,11 @@ class StatsArtistsPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final artists = ref.watch( playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.artists), + .select((s) => s.whenData((s) => s.artists)), ); + final artistsData = artists.asData?.value ?? []; + return Scaffold( appBar: const PageWindowTitleBar( automaticallyImplyLeading: true, @@ -24,9 +26,9 @@ class StatsArtistsPage extends HookConsumerWidget { title: Text("Artists"), ), body: ListView.builder( - itemCount: artists.length, + itemCount: artistsData.length, itemBuilder: (context, index) { - final artist = artists[index]; + final artist = artistsData[index]; return StatsArtistItem( artist: artist.artist, info: Text("${compactNumberFormatter.format(artist.count)} plays"), diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index ee141475..e881ec70 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -18,9 +18,11 @@ class StatsStreamFeesPage extends HookConsumerWidget { final artists = ref.watch( playbackHistoryTopProvider(HistoryDuration.days30) - .select((value) => value.artists), + .select((value) => value.whenData((s) => s.artists)), ); + final artistsData = artists.asData?.value ?? []; + return Scaffold( appBar: const PageWindowTitleBar( automaticallyImplyLeading: true, @@ -49,9 +51,9 @@ class StatsStreamFeesPage extends HookConsumerWidget { ), ), SliverList.builder( - itemCount: artists.length, + itemCount: artistsData.length, itemBuilder: (context, index) { - final artist = artists[index]; + final artist = artistsData[index]; return StatsArtistItem( artist: artist.artist, info: Text(usdFormatter.format(artist.count * 0.005)), diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index ea0a0c10..1d6a5844 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -16,9 +16,11 @@ class StatsMinutesPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final topTracks = ref.watch( playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.tracks), + .select((s) => s.whenData((s) => s.tracks)), ); + final topTracksData = topTracks.asData?.value ?? []; + return Scaffold( appBar: const PageWindowTitleBar( title: Text("Minutes listened"), @@ -27,9 +29,9 @@ class StatsMinutesPage extends HookConsumerWidget { ), body: ListView.separated( separatorBuilder: (context, index) => const Gap(8), - itemCount: topTracks.length, + itemCount: topTracksData.length, itemBuilder: (context, index) { - final (:track, :count) = topTracks[index]; + final (:track, :count) = topTracksData[index]; return StatsTrackItem( track: track, diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index d31f1dfa..94f8ce9d 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -14,9 +14,11 @@ class StatsPlaylistsPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final playlists = ref.watch( playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.playlists), + .select((s) => s.whenData((s) => s.playlists)), ); + final playlistsData = playlists.asData?.value ?? []; + return Scaffold( appBar: const PageWindowTitleBar( automaticallyImplyLeading: true, @@ -24,11 +26,11 @@ class StatsPlaylistsPage extends HookConsumerWidget { title: Text("Playlists"), ), body: ListView.builder( - itemCount: playlists.length, + itemCount: playlistsData.length, itemBuilder: (context, index) { - final playlist = playlists[index]; + final playlist = playlistsData[index]; return StatsPlaylistItem( - playlist: playlist.playlist.playlist, + playlist: playlist.playlist, info: Text("${compactNumberFormatter.format(playlist.count)} plays"), ); diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index 3df34483..41f2d33a 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -16,9 +16,11 @@ class StatsStreamsPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final topTracks = ref.watch( playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.tracks), + .select((s) => s.whenData((s) => s.tracks)), ); + final topTracksData = topTracks.asData?.value ?? []; + return Scaffold( appBar: const PageWindowTitleBar( title: Text("Streamed songs"), @@ -27,9 +29,9 @@ class StatsStreamsPage extends HookConsumerWidget { ), body: ListView.separated( separatorBuilder: (context, index) => const Gap(8), - itemCount: topTracks.length, + itemCount: topTracksData.length, itemBuilder: (context, index) { - final (:track, :count) = topTracks[index]; + final (:track, :count) = topTracksData[index]; return StatsTrackItem( track: track, diff --git a/lib/provider/audio_player/audio_player_streams.dart b/lib/provider/audio_player/audio_player_streams.dart index 42944075..368fc6d9 100644 --- a/lib/provider/audio_player/audio_player_streams.dart +++ b/lib/provider/audio_player/audio_player_streams.dart @@ -45,8 +45,8 @@ class AudioPlayerStreamListeners { UserPreferences get preferences => ref.read(userPreferencesProvider); Discord get discord => ref.read(discordProvider); AudioPlayerState get audioPlayerState => ref.read(audioPlayerProvider); - PlaybackHistoryNotifier get history => - ref.read(playbackHistoryProvider.notifier); + PlaybackHistoryActions get history => + ref.read(playbackHistoryActionsProvider); Future updatePalette() async { final palette = ref.read(paletteProvider); diff --git a/lib/provider/history/history.dart b/lib/provider/history/history.dart index 4436626d..0c20a9e5 100644 --- a/lib/provider/history/history.dart +++ b/lib/provider/history/history.dart @@ -1,129 +1,68 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/spotify_provider.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; -class PlaybackHistoryState { - final List items; - const PlaybackHistoryState({this.items = const []}); - - factory PlaybackHistoryState.fromJson(Map json) { - return PlaybackHistoryState( - items: json["items"] - ?.map( - (json) => PlaybackHistoryItem.fromJson(json), - ) - .toList() - .cast() ?? - [], - ); - } - - Map toJson() { - return { - "items": items.map((s) => s.toJson()).toList(), - }; - } - - PlaybackHistoryState copyWith({ - List? items, - }) { - return PlaybackHistoryState(items: items ?? this.items); - } -} - -class PlaybackHistoryNotifier - extends PersistedStateNotifier { +class PlaybackHistoryActions { final Ref ref; - PlaybackHistoryNotifier(this.ref) - : super(const PlaybackHistoryState(), "playback_history"); + AppDatabase get _db => ref.read(databaseProvider); - SpotifyApi get spotify => ref.read(spotifyProvider); + PlaybackHistoryActions(this.ref); - @override - FutureOr fromJson(Map json) => - PlaybackHistoryState.fromJson(json); - - @override - Map toJson() { - return state.toJson(); + Future _batchInsertHistoryEntries( + List entries) async { + await _db.batch((batch) { + batch.insertAll(_db.historyTable, entries); + }); } - void addPlaylists(List playlists) { - state = state.copyWith( - items: [ - ...state.items, - for (final playlist in playlists) - PlaybackHistoryItem.playlist( - date: DateTime.now(), playlist: playlist), - ], - ); + Future addPlaylists(List playlists) async { + await _batchInsertHistoryEntries([ + for (final playlist in playlists) + HistoryTableCompanion.insert( + type: HistoryEntryType.playlist, + itemId: playlist.id!, + data: playlist.toJson(), + ), + ]); } - void addAlbums(List albums) { - state = state.copyWith( - items: [ - ...state.items, - for (final album in albums) - PlaybackHistoryItem.album(date: DateTime.now(), album: album), - ], - ); + Future addAlbums(List albums) async { + await _batchInsertHistoryEntries([ + for (final albums in albums) + HistoryTableCompanion.insert( + type: HistoryEntryType.album, + itemId: albums.id!, + data: albums.toJson(), + ), + ]); } - void addTrack(Track track) async { - // For some reason Track's artists images are `null` - // so we need to fetch them from the API - final artists = - await spotify.artists.list(track.artists!.map((e) => e.id!).toList()); - - track.artists = artists.toList(); - - state = state.copyWith( - items: [ - ...state.items, - PlaybackHistoryItem.track(date: DateTime.now(), track: track), - ], - ); + Future addTracks(List tracks) async { + await _batchInsertHistoryEntries([ + for (final track in tracks) + HistoryTableCompanion.insert( + type: HistoryEntryType.track, + itemId: track.id!, + data: track.toJson(), + ), + ]); } - void clear() { - state = state.copyWith(items: []); + Future addTrack(Track track) async { + await _db.into(_db.historyTable).insert( + HistoryTableCompanion.insert( + type: HistoryEntryType.track, + itemId: track.id!, + data: track.toJson(), + ), + ); + } + + Future clear() async { + _db.delete(_db.historyTable).go(); } } -final playbackHistoryProvider = - StateNotifierProvider( - (ref) => PlaybackHistoryNotifier(ref), -); - -typedef PlaybackHistoryGrouped = ({ - List tracks, - List albums, - List playlists, -}); - -final playbackHistoryGroupedProvider = Provider((ref) { - final history = ref.watch(playbackHistoryProvider); - final tracks = history.items - .whereType() - .sorted((a, b) => b.date.compareTo(a.date)) - .toList(); - final albums = history.items - .whereType() - .sorted((a, b) => b.date.compareTo(a.date)) - .toList(); - final playlists = history.items - .whereType() - .sorted((a, b) => b.date.compareTo(a.date)) - .toList(); - - return ( - tracks: tracks, - albums: albums, - playlists: playlists, - ); -}); +final playbackHistoryActionsProvider = + Provider((ref) => PlaybackHistoryActions(ref)); diff --git a/lib/provider/history/recent.dart b/lib/provider/history/recent.dart index 9953858d..4e445500 100644 --- a/lib/provider/history/recent.dart +++ b/lib/provider/history/recent.dart @@ -1,40 +1,55 @@ import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/history/state.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; -final recentlyPlayedItems = Provider((ref) { - return ref.watch( - playbackHistoryProvider.select( - (s) => s.items - .toSet() - // unique items - .whereIndexed( - (index, item) => - index == - s.items.lastIndexWhere( - (e) => switch ((e, item)) { - ( - PlaybackHistoryPlaylist(:final playlist), - PlaybackHistoryPlaylist(playlist: final playlist2) - ) => - playlist.id == playlist2.id, - ( - PlaybackHistoryAlbum(:final album), - PlaybackHistoryAlbum(album: final album2) - ) => - album.id == album2.id, - _ => false, - }, - ), - ) - .where( - (s) => s is PlaybackHistoryPlaylist || s is PlaybackHistoryAlbum, - ) - .take(10) - .sortedBy((s) => s.date) - .reversed - .toList(), - ), - ); -}); +class RecentlyPlayedItemNotifier extends AsyncNotifier> { + @override + build() async { + final database = ref.watch(databaseProvider); + + final uniqueItemIds = + await (database.selectOnly(database.historyTable, distinct: true) + ..addColumns([database.historyTable.itemId]) + ..where( + database.historyTable.type.isIn([ + HistoryEntryType.playlist.name, + HistoryEntryType.album.name, + ]), + ) + ..limit(10)) + .map((row) => row.read(database.historyTable.itemId)) + .get() + .then((value) => value.whereNotNull().toList()); + + final query = database.select(database.historyTable) + ..where( + (tbl) => + tbl.type.isIn([ + HistoryEntryType.playlist.name, + HistoryEntryType.album.name, + ]) & + tbl.itemId.isIn(uniqueItemIds), + ) + ..orderBy([ + (tbl) => OrderingTerm( + expression: tbl.createdAt, + mode: OrderingMode.desc, + ), + ]); + + final subscription = query.watch().listen((event) { + state = AsyncData(event); + }); + + ref.onDispose(() => subscription.cancel()); + + return await query.get(); + } +} + +final recentlyPlayedItems = + AsyncNotifierProvider>( + () => RecentlyPlayedItemNotifier(), +); diff --git a/lib/provider/history/summary.dart b/lib/provider/history/summary.dart index 2aa86ac9..99df4c11 100644 --- a/lib/provider/history/summary.dart +++ b/lib/provider/history/summary.dart @@ -1,62 +1,197 @@ -import 'package:collection/collection.dart'; +import 'dart:async'; +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:drift/extensions/json1.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/provider/history/history.dart'; -import 'package:spotube/provider/history/state.dart'; -import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; -final playbackHistorySummaryProvider = Provider((ref) { - final (:tracks, :albums, :playlists) = - ref.watch(playbackHistoryGroupedProvider); +class PlaybackHistorySummary { + final Duration duration; + final int tracks; + final int artists; + final double fees; + final int albums; + final int playlists; - final totalDurationListened = tracks.fold( - Duration.zero, - (previousValue, element) => previousValue + element.track.duration!, - ); + const PlaybackHistorySummary({ + required this.duration, + required this.tracks, + required this.artists, + required this.fees, + required this.albums, + required this.playlists, + }); - final totalTracksListened = tracks - .whereIndexed( - (i, track) => - i == tracks.lastIndexWhere((e) => e.track.id == track.track.id), - ) - .length; + PlaybackHistorySummary copyWith({ + Duration? duration, + int? tracks, + int? artists, + double? fees, + int? albums, + int? playlists, + }) { + return PlaybackHistorySummary( + duration: duration ?? this.duration, + tracks: tracks ?? this.tracks, + artists: artists ?? this.artists, + fees: fees ?? this.fees, + albums: albums ?? this.albums, + playlists: playlists ?? this.playlists, + ); + } +} - final artists = - tracks.map((e) => e.track.artists).expand((e) => e ?? []).toList(); +class PlaybackHistorySummaryNotifier + extends AsyncNotifier { + @override + build() async { + final database = ref.watch(databaseProvider); - final totalArtistsListened = artists - .whereIndexed( - (i, artist) => i == artists.lastIndexWhere((e) => e.id == artist.id), - ) - .length; + final uniqItemIdCountingCol = + database.historyTable.itemId.count(distinct: true); + final itemIdCountingCol = database.historyTable.itemId.count(); + final durationSumJsonColumn = + database.historyTable.data.jsonExtract(r"$.duration_ms").sum(); + final artistCountingCol = + database.historyTable.data.jsonExtract(r"$.artists"); - final totalAlbumsListened = albums - .whereIndexed( - (i, album) => - i == albums.lastIndexWhere((e) => e.album.id == album.album.id), - ) - .length; + final totalTracksListenedQuery = (database.selectOnly(database.historyTable) + ..addColumns([uniqItemIdCountingCol]) + ..where( + database.historyTable.type.equals(HistoryEntryType.track.name))) + .map((row) => row.read(uniqItemIdCountingCol)); - final totalPlaylistsListened = playlists - .whereIndexed( - (i, playlist) => - i == - playlists - .lastIndexWhere((e) => e.playlist.id == playlist.playlist.id), - ) - .length; + final totalDurationListenedQuery = (database + .selectOnly(database.historyTable) + ..addColumns([durationSumJsonColumn]) + ..where( + database.historyTable.type.equals(HistoryEntryType.track.name))) + .map( + (row) => Duration(milliseconds: row.read(durationSumJsonColumn) ?? 0), + ); - final tracksThisMonth = ref.watch( - playbackHistoryTopProvider(HistoryDuration.days30).select((s) => s.tracks), - ); + final totalArtistsListenedQuery = + (database.selectOnly(database.historyTable) + ..addColumns([artistCountingCol]) + ..where( + database.historyTable.type.equals(HistoryEntryType.track.name), + )) + .map( + (row) { + final data = jsonDecode(row.read(artistCountingCol)!) as List; + return data.map((e) => e['id'] as String).cast().toList(); + }, + ); - final streams = tracksThisMonth.fold(0, (acc, el) => acc + el.count); + final totalAlbumsListenedQuery = (database.selectOnly(database.historyTable) + ..addColumns([uniqItemIdCountingCol]) + ..where( + database.historyTable.type.equals(HistoryEntryType.album.name))) + .map((row) => row.read(uniqItemIdCountingCol)); - return ( - duration: totalDurationListened, - tracks: totalTracksListened, - artists: totalArtistsListened, - fees: streams * 0.005, // Spotify pays $0.003 to $0.005 - albums: totalAlbumsListened, - playlists: totalPlaylistsListened, - ); -}); + final totalPlaylistsListenedQuery = + (database.selectOnly(database.historyTable) + ..addColumns([uniqItemIdCountingCol]) + ..where( + database.historyTable.type + .equals(HistoryEntryType.playlist.name), + )) + .map((row) => row.read(uniqItemIdCountingCol)); + + final oldestDate = DateTime.now().copyWith(day: 1, hour: 0, minute: 0); + final newestDate = DateTime.now().copyWith(day: 30, hour: 23, minute: 59); + final totalTracksListenedThisMonthQuery = + (database.selectOnly(database.historyTable) + ..addColumns([itemIdCountingCol]) + ..where( + database.historyTable.type.equals( + HistoryEntryType.track.name, + ) & + database.historyTable.createdAt + .isBetweenValues(oldestDate, newestDate), + )) + .map((row) => row.read(itemIdCountingCol)); + + final subscriptions = [ + totalTracksListenedQuery.watchSingle().listen((event) { + if (event == null || state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + tracks: event, + )); + }), + totalDurationListenedQuery.watchSingle().listen((event) { + if (state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + duration: event, + )); + }), + totalArtistsListenedQuery.watch().listen((event) { + if (state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + artists: event.expand((e) => e).toSet().length, + )); + }), + totalAlbumsListenedQuery.watchSingle().listen((event) { + if (event == null || state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + albums: event, + )); + }), + totalPlaylistsListenedQuery.watchSingle().listen((event) { + if (event == null || state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + playlists: event, + )); + }), + totalTracksListenedThisMonthQuery.watchSingle().listen((event) { + if (event == null || state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + fees: event * 0.005, + )); + }), + ]; + + ref.onDispose(() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + }); + + return database.transaction(() async { + final totalTracksListened = + await totalTracksListenedQuery.getSingle() ?? 0; + + final totalDurationListened = + await totalDurationListenedQuery.getSingle(); + + final totalArtistsListened = await totalArtistsListenedQuery + .get() + .then((value) => value.expand((e) => e).toSet().length); + + final totalAlbumsListened = + await totalAlbumsListenedQuery.getSingle() ?? 0; + + final totalPlaylistsListened = + await totalPlaylistsListenedQuery.getSingle() ?? 0; + + final totalTracksListenedThisMonth = + await totalTracksListenedThisMonthQuery.getSingle() ?? 0; + + return PlaybackHistorySummary( + duration: totalDurationListened, + tracks: totalTracksListened, + artists: totalArtistsListened, + fees: totalTracksListenedThisMonth * 0.005, + albums: totalAlbumsListened, + playlists: totalPlaylistsListened, + ); + }); + } +} + +final playbackHistorySummaryProvider = AsyncNotifierProvider< + PlaybackHistorySummaryNotifier, PlaybackHistorySummary>( + () => PlaybackHistorySummaryNotifier(), +); diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart index 7d4594f0..aa12c9b3 100644 --- a/lib/provider/history/top.dart +++ b/lib/provider/history/top.dart @@ -1,95 +1,212 @@ +import 'dart:async'; + import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/provider/history/history.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/history/state.dart'; final playbackHistoryTopDurationProvider = StateProvider((ref) => HistoryDuration.days30); -final playbackHistoryTopProvider = - Provider.family((ref, HistoryDuration durationState) { - final grouped = ref.watch(playbackHistoryGroupedProvider); +typedef PlaybackHistoryTrack = ({int count, Track track}); +typedef PlaybackHistoryAlbum = ({int count, AlbumSimple album}); +typedef PlaybackHistoryPlaylist = ({int count, PlaylistSimple playlist}); +typedef PlaybackHistoryArtist = ({int count, Artist artist}); - final duration = switch (durationState) { - HistoryDuration.allTime => const Duration(days: 365 * 2003), - HistoryDuration.days7 => const Duration(days: 7), - HistoryDuration.days30 => const Duration(days: 30), - HistoryDuration.months6 => const Duration(days: 30 * 6), - HistoryDuration.year => const Duration(days: 365), - HistoryDuration.years2 => const Duration(days: 365 * 2), - }; - final tracks = grouped.tracks - .where( - (item) => item.date.isAfter( - DateTime.now().subtract(duration), - ), - ) - .toList(); - final albums = grouped.albums - .where( - (item) => item.date.isAfter( - DateTime.now().subtract(duration), - ), - ) - .toList(); +class PlaybackHistoryTopState { + final List tracks; + final List albums; + final List playlists; + final List artists; - final playlists = grouped.playlists - .where( - (item) => item.date.isAfter( - DateTime.now().subtract(duration), - ), - ) - .toList(); + const PlaybackHistoryTopState({ + required this.tracks, + required this.albums, + required this.playlists, + required this.artists, + }); - final tracksWithCount = groupBy( - tracks, - (track) => track.track.id!, - ) - .entries - .map((entry) { - return (count: entry.value.length, track: entry.value.first.track); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); + PlaybackHistoryTopState copyWith({ + List? tracks, + List? albums, + List? playlists, + List? artists, + }) { + return PlaybackHistoryTopState( + tracks: tracks ?? this.tracks, + albums: albums ?? this.albums, + playlists: playlists ?? this.playlists, + artists: artists ?? this.artists, + ); + } +} - final albumsWithTrackAlbums = [ - for (final historicAlbum in albums) historicAlbum.album, - for (final track in tracks) track.track.album! - ]; +class PlaybackHistoryTopNotifier + extends FamilyAsyncNotifier { + @override + build(arg) async { + final database = ref.watch(databaseProvider); - final albumsWithCount = groupBy(albumsWithTrackAlbums, (album) => album.id!) - .entries - .map((entry) { - return (count: entry.value.length, album: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); + final duration = switch (arg) { + HistoryDuration.allTime => const Duration(days: 365 * 2003), + HistoryDuration.days7 => const Duration(days: 7), + HistoryDuration.days30 => const Duration(days: 30), + HistoryDuration.months6 => const Duration(days: 30 * 6), + HistoryDuration.year => const Duration(days: 365), + HistoryDuration.years2 => const Duration(days: 365 * 2), + }; - final artists = - tracks.map((track) => track.track.artists).expand((e) => e ?? []); + final tracksQuery = (database.select(database.historyTable) + ..where( + (tbl) => + tbl.type.equalsValue(HistoryEntryType.track) & + tbl.createdAt.isBiggerOrEqualValue( + DateTime.now().subtract(duration), + ), + )); - final artistsWithCount = groupBy(artists, (artist) => artist.id!) - .entries - .map((entry) { - return (count: entry.value.length, artist: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); + final albumsQuery = database.select(database.historyTable) + ..where( + (tbl) => + tbl.type.equalsValue(HistoryEntryType.album) & + tbl.createdAt.isBiggerOrEqualValue( + DateTime.now().subtract(duration), + ), + ); - final playlistsWithCount = - groupBy(playlists, (playlist) => playlist.playlist.id!) - .entries - .map((entry) { - return (count: entry.value.length, playlist: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); + final playlistsQuery = database.select(database.historyTable) + ..where( + (tbl) => + tbl.type.equalsValue(HistoryEntryType.playlist) & + tbl.createdAt.isBiggerOrEqualValue( + DateTime.now().subtract(duration), + ), + ); - return ( - tracks: tracksWithCount, - albums: albumsWithCount, - artists: artistsWithCount, - playlists: playlistsWithCount, - ); -}); + final subscriptions = [ + tracksQuery.watch().listen((event) { + if (state.asData == null) return; + final artists = event + .map((track) => track.track!.artists) + .expand((e) => e ?? []); + state = AsyncData(state.asData!.value.copyWith( + tracks: getTracksWithCount(event), + artists: getArtistsWithCount(artists), + )); + }), + albumsQuery.watch().listen((event) async { + if (state.asData == null) return; + final tracks = await tracksQuery.get(); + + final albumsWithTrackAlbums = [ + for (final historicAlbum in event) historicAlbum.album!, + for (final track in tracks) track.track!.album! + ]; + + state = AsyncData(state.asData!.value.copyWith( + albums: getAlbumsWithCount(albumsWithTrackAlbums), + )); + }), + playlistsQuery.watch().listen((event) { + if (state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + playlists: getPlaylistsWithCount(event), + )); + }), + ]; + + ref.onDispose(() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + }); + + return database.transaction(() async { + final tracks = await tracksQuery.get(); + final albums = await albumsQuery.get(); + final playlists = await playlistsQuery.get(); + + final tracksWithCount = getTracksWithCount(tracks); + + final albumsWithTrackAlbums = [ + for (final historicAlbum in albums) historicAlbum.album!, + for (final track in tracks) track.track!.album! + ]; + + final albumsWithCount = getAlbumsWithCount(albumsWithTrackAlbums); + + final artists = tracks + .map((track) => track.track!.artists) + .expand((e) => e ?? []); + + final artistsWithCount = getArtistsWithCount(artists); + + final playlistsWithCount = getPlaylistsWithCount(playlists); + + return PlaybackHistoryTopState( + tracks: tracksWithCount, + albums: albumsWithCount, + artists: artistsWithCount, + playlists: playlistsWithCount, + ); + }); + } + + List getTracksWithCount(List tracks) { + return groupBy( + tracks, + (track) => track.track!.id!, + ) + .entries + .map((entry) { + return (count: entry.value.length, track: entry.value.first.track!); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } + + List getAlbumsWithCount( + List albumsWithTrackAlbums, + ) { + return groupBy(albumsWithTrackAlbums, (album) => album.id!) + .entries + .map((entry) { + return (count: entry.value.length, album: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } + + List getArtistsWithCount(Iterable artists) { + return groupBy(artists, (artist) => artist.id!) + .entries + .map((entry) { + return (count: entry.value.length, artist: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } + + List getPlaylistsWithCount( + List playlists, + ) { + return groupBy(playlists, (playlist) => playlist.playlist!.id!) + .entries + .map((entry) { + return ( + count: entry.value.length, + playlist: entry.value.first.playlist!, + ); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } +} + +final playbackHistoryTopProvider = AsyncNotifierProviderFamily< + PlaybackHistoryTopNotifier, + PlaybackHistoryTopState, + HistoryDuration>(PlaybackHistoryTopNotifier.new); diff --git a/lib/provider/server/routes/connect.dart b/lib/provider/server/routes/connect.dart index a2fa70b8..8e75a87e 100644 --- a/lib/provider/server/routes/connect.dart +++ b/lib/provider/server/routes/connect.dart @@ -40,8 +40,8 @@ class ServerConnectRoutes { AudioPlayerNotifier get audioPlayerNotifier => ref.read(audioPlayerProvider.notifier); - PlaybackHistoryNotifier get historyNotifier => - ref.read(playbackHistoryProvider.notifier); + PlaybackHistoryActions get historyNotifier => + ref.read(playbackHistoryActionsProvider); Stream get connectClientStream => _connectClientStreamController.stream; From b495ed4ac02a7a24a9a9b0c79f8aa1cd0363438b Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 29 Jun 2024 17:09:58 +0600 Subject: [PATCH 154/261] fix: null exception in album page navigated from /home --- lib/pages/album/album.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/pages/album/album.dart b/lib/pages/album/album.dart index dcdc2ce7..0c6cfd69 100644 --- a/lib/pages/album/album.dart +++ b/lib/pages/album/album.dart @@ -46,7 +46,8 @@ class AlbumPage extends HookConsumerWidget { }, ), routePath: "/album/${album.id}", - shareUrl: album.externalUrls!.spotify!, + shareUrl: album.externalUrls?.spotify ?? + "https://open.spotify.com/album/${album.id}", isLiked: isSavedAlbum.asData?.value ?? false, onHeart: isSavedAlbum.asData?.value == null ? null From 1cfd377c298e98f460a082e1196c441e71291079 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 30 Jun 2024 11:01:40 +0600 Subject: [PATCH 155/261] refactor: synced lyric cache to use drift db --- .../sections/header/header_buttons.dart | 4 +- lib/main.dart | 5 - lib/models/database/database.dart | 4 + lib/models/database/database.g.dart | 323 ++++++++++++++++++ lib/models/database/tables/lyrics.dart | 8 + .../database/typeconverters/subtitle.dart | 13 + lib/pages/root/root_app.dart | 9 - lib/provider/spotify/lyrics/synced.dart | 38 ++- lib/provider/spotify/playlist/liked.dart | 18 +- lib/provider/spotify/spotify.dart | 5 +- lib/provider/spotify/utils/json_cast.dart | 21 ++ lib/provider/spotify/utils/persistence.dart | 2 +- lib/utils/persisted_change_notifier.dart | 55 --- lib/utils/persisted_state_notifier.dart | 164 --------- 14 files changed, 401 insertions(+), 268 deletions(-) create mode 100644 lib/models/database/tables/lyrics.dart create mode 100644 lib/models/database/typeconverters/subtitle.dart create mode 100644 lib/provider/spotify/utils/json_cast.dart delete mode 100644 lib/utils/persisted_change_notifier.dart delete mode 100644 lib/utils/persisted_state_notifier.dart diff --git a/lib/components/tracks_view/sections/header/header_buttons.dart b/lib/components/tracks_view/sections/header/header_buttons.dart index e9fbf6bb..54e0f0cf 100644 --- a/lib/components/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/tracks_view/sections/header/header_buttons.dart @@ -131,7 +131,9 @@ class TrackViewHeaderButtons extends HookConsumerWidget { ); } } finally { - isLoading.value = false; + if (context.mounted) { + isLoading.value = false; + } } } diff --git a/lib/main.dart b/lib/main.dart index 9b92a21d..69c89062 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,7 +33,6 @@ import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:spotube/themes/theme.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; import 'package:system_theme/system_theme.dart'; import 'package:path_provider/path_provider.dart'; @@ -85,10 +84,6 @@ Future main(List rawArgs) async { Hive.init(hiveCacheDir); - await PersistedStateNotifier.initializeBoxes( - path: hiveCacheDir, - ); - if (kIsDesktop) { await localNotifier.setup(appName: "Spotube"); await WindowManagerTools.initialize(); diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 9b47aaab..609d6771 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -9,6 +9,7 @@ import 'package:media_kit/media_kit.dart' hide Track; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotify/spotify.dart' hide Playlist; +import 'package:spotube/models/lyrics.dart'; import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -28,12 +29,14 @@ part 'tables/skip_segment.dart'; part 'tables/source_match.dart'; part 'tables/audio_player_state.dart'; part 'tables/history.dart'; +part 'tables/lyrics.dart'; part 'typeconverters/color.dart'; part 'typeconverters/locale.dart'; part 'typeconverters/string_list.dart'; part 'typeconverters/encrypted_text.dart'; part 'typeconverters/map.dart'; +part 'typeconverters/subtitle.dart'; @DriftDatabase( tables: [ @@ -47,6 +50,7 @@ part 'typeconverters/map.dart'; PlaylistTable, PlaylistMediaTable, HistoryTable, + LyricsTable, ], ) class AppDatabase extends _$AppDatabase { diff --git a/lib/models/database/database.g.dart b/lib/models/database/database.g.dart index bb7d2fb6..1e585fa8 100644 --- a/lib/models/database/database.g.dart +++ b/lib/models/database/database.g.dart @@ -3709,6 +3709,218 @@ class HistoryTableCompanion extends UpdateCompanion { } } +class $LyricsTableTable extends LyricsTable + with TableInfo<$LyricsTableTable, LyricsTableData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $LyricsTableTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: + GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); + static const VerificationMeta _trackIdMeta = + const VerificationMeta('trackId'); + @override + late final GeneratedColumn trackId = GeneratedColumn( + 'track_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _dataMeta = const VerificationMeta('data'); + @override + late final GeneratedColumnWithTypeConverter data = + GeneratedColumn('data', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter($LyricsTableTable.$converterdata); + @override + List get $columns => [id, trackId, data]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'lyrics_table'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('track_id')) { + context.handle(_trackIdMeta, + trackId.isAcceptableOrUnknown(data['track_id']!, _trackIdMeta)); + } else if (isInserting) { + context.missing(_trackIdMeta); + } + context.handle(_dataMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {id}; + @override + LyricsTableData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return LyricsTableData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}id'])!, + trackId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}track_id'])!, + data: $LyricsTableTable.$converterdata.fromSql(attachedDatabase + .typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}data'])!), + ); + } + + @override + $LyricsTableTable createAlias(String alias) { + return $LyricsTableTable(attachedDatabase, alias); + } + + static TypeConverter $converterdata = + SubtitleTypeConverter(); +} + +class LyricsTableData extends DataClass implements Insertable { + final int id; + final String trackId; + final SubtitleSimple data; + const LyricsTableData( + {required this.id, required this.trackId, required this.data}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['track_id'] = Variable(trackId); + { + map['data'] = + Variable($LyricsTableTable.$converterdata.toSql(data)); + } + return map; + } + + LyricsTableCompanion toCompanion(bool nullToAbsent) { + return LyricsTableCompanion( + id: Value(id), + trackId: Value(trackId), + data: Value(data), + ); + } + + factory LyricsTableData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LyricsTableData( + id: serializer.fromJson(json['id']), + trackId: serializer.fromJson(json['trackId']), + data: serializer.fromJson(json['data']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'trackId': serializer.toJson(trackId), + 'data': serializer.toJson(data), + }; + } + + LyricsTableData copyWith({int? id, String? trackId, SubtitleSimple? data}) => + LyricsTableData( + id: id ?? this.id, + trackId: trackId ?? this.trackId, + data: data ?? this.data, + ); + @override + String toString() { + return (StringBuffer('LyricsTableData(') + ..write('id: $id, ') + ..write('trackId: $trackId, ') + ..write('data: $data') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, trackId, data); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LyricsTableData && + other.id == this.id && + other.trackId == this.trackId && + other.data == this.data); +} + +class LyricsTableCompanion extends UpdateCompanion { + final Value id; + final Value trackId; + final Value data; + const LyricsTableCompanion({ + this.id = const Value.absent(), + this.trackId = const Value.absent(), + this.data = const Value.absent(), + }); + LyricsTableCompanion.insert({ + this.id = const Value.absent(), + required String trackId, + required SubtitleSimple data, + }) : trackId = Value(trackId), + data = Value(data); + static Insertable custom({ + Expression? id, + Expression? trackId, + Expression? data, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (trackId != null) 'track_id': trackId, + if (data != null) 'data': data, + }); + } + + LyricsTableCompanion copyWith( + {Value? id, Value? trackId, Value? data}) { + return LyricsTableCompanion( + id: id ?? this.id, + trackId: trackId ?? this.trackId, + data: data ?? this.data, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (trackId.present) { + map['track_id'] = Variable(trackId.value); + } + if (data.present) { + map['data'] = + Variable($LyricsTableTable.$converterdata.toSql(data.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LyricsTableCompanion(') + ..write('id: $id, ') + ..write('trackId: $trackId, ') + ..write('data: $data') + ..write(')')) + .toString(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); _$AppDatabaseManager get managers => _$AppDatabaseManager(this); @@ -3728,6 +3940,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $PlaylistMediaTableTable playlistMediaTable = $PlaylistMediaTableTable(this); late final $HistoryTableTable historyTable = $HistoryTableTable(this); + late final $LyricsTableTable lyricsTable = $LyricsTableTable(this); late final Index uniqueBlacklist = Index('unique_blacklist', 'CREATE UNIQUE INDEX unique_blacklist ON blacklist_table (element_type, element_id)'); late final Index uniqTrackMatch = Index('uniq_track_match', @@ -3747,6 +3960,7 @@ abstract class _$AppDatabase extends GeneratedDatabase { playlistTable, playlistMediaTable, historyTable, + lyricsTable, uniqueBlacklist, uniqTrackMatch ]; @@ -5492,6 +5706,113 @@ class $$HistoryTableTableOrderingComposer ColumnOrderings(column, joinBuilders: joinBuilders)); } +typedef $$LyricsTableTableInsertCompanionBuilder = LyricsTableCompanion + Function({ + Value id, + required String trackId, + required SubtitleSimple data, +}); +typedef $$LyricsTableTableUpdateCompanionBuilder = LyricsTableCompanion + Function({ + Value id, + Value trackId, + Value data, +}); + +class $$LyricsTableTableTableManager extends RootTableManager< + _$AppDatabase, + $LyricsTableTable, + LyricsTableData, + $$LyricsTableTableFilterComposer, + $$LyricsTableTableOrderingComposer, + $$LyricsTableTableProcessedTableManager, + $$LyricsTableTableInsertCompanionBuilder, + $$LyricsTableTableUpdateCompanionBuilder> { + $$LyricsTableTableTableManager(_$AppDatabase db, $LyricsTableTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$LyricsTableTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$LyricsTableTableOrderingComposer(ComposerState(db, table)), + getChildManagerBuilder: (p) => + $$LyricsTableTableProcessedTableManager(p), + getUpdateCompanionBuilder: ({ + Value id = const Value.absent(), + Value trackId = const Value.absent(), + Value data = const Value.absent(), + }) => + LyricsTableCompanion( + id: id, + trackId: trackId, + data: data, + ), + getInsertCompanionBuilder: ({ + Value id = const Value.absent(), + required String trackId, + required SubtitleSimple data, + }) => + LyricsTableCompanion.insert( + id: id, + trackId: trackId, + data: data, + ), + )); +} + +class $$LyricsTableTableProcessedTableManager extends ProcessedTableManager< + _$AppDatabase, + $LyricsTableTable, + LyricsTableData, + $$LyricsTableTableFilterComposer, + $$LyricsTableTableOrderingComposer, + $$LyricsTableTableProcessedTableManager, + $$LyricsTableTableInsertCompanionBuilder, + $$LyricsTableTableUpdateCompanionBuilder> { + $$LyricsTableTableProcessedTableManager(super.$state); +} + +class $$LyricsTableTableFilterComposer + extends FilterComposer<_$AppDatabase, $LyricsTableTable> { + $$LyricsTableTableFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get data => $state.composableBuilder( + column: $state.table.data, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); +} + +class $$LyricsTableTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $LyricsTableTable> { + $$LyricsTableTableOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get trackId => $state.composableBuilder( + column: $state.table.trackId, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get data => $state.composableBuilder( + column: $state.table.data, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + class _$AppDatabaseManager { final _$AppDatabase _db; _$AppDatabaseManager(this._db); @@ -5515,4 +5836,6 @@ class _$AppDatabaseManager { $$PlaylistMediaTableTableTableManager(_db, _db.playlistMediaTable); $$HistoryTableTableTableManager get historyTable => $$HistoryTableTableTableManager(_db, _db.historyTable); + $$LyricsTableTableTableManager get lyricsTable => + $$LyricsTableTableTableManager(_db, _db.lyricsTable); } diff --git a/lib/models/database/tables/lyrics.dart b/lib/models/database/tables/lyrics.dart new file mode 100644 index 00000000..7c4c7f8f --- /dev/null +++ b/lib/models/database/tables/lyrics.dart @@ -0,0 +1,8 @@ +part of '../database.dart'; + +class LyricsTable extends Table { + IntColumn get id => integer().autoIncrement()(); + + TextColumn get trackId => text()(); + TextColumn get data => text().map(SubtitleTypeConverter())(); +} diff --git a/lib/models/database/typeconverters/subtitle.dart b/lib/models/database/typeconverters/subtitle.dart new file mode 100644 index 00000000..25fa4ad5 --- /dev/null +++ b/lib/models/database/typeconverters/subtitle.dart @@ -0,0 +1,13 @@ +part of '../database.dart'; + +class SubtitleTypeConverter extends TypeConverter { + @override + SubtitleSimple fromSql(String fromDb) { + return SubtitleSimple.fromJson(jsonDecode(fromDb)); + } + + @override + String toSql(SubtitleSimple value) { + return jsonEncode(value.toJson()); + } +} diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 322a8731..402a7cf0 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -5,7 +5,6 @@ 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:shared_preferences/shared_preferences.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; @@ -19,7 +18,6 @@ import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/server/routes/connect.dart'; import 'package:spotube/services/connectivity_adapter.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -41,13 +39,6 @@ class RootApp extends HookConsumerWidget { useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { ServiceUtils.checkForUpdates(context, ref); - - final sharedPreferences = await SharedPreferences.getInstance(); - - if (sharedPreferences.getBool(kIsUsingEncryption) == false && - context.mounted) { - await PersistedStateNotifier.showNoEncryptionDialog(context); - } }); final subscriptions = [ diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index ef83a1a1..bcf2a162 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -1,11 +1,6 @@ part of '../spotify.dart'; -class SyncedLyricsNotifier extends FamilyAsyncNotifier - with Persistence { - SyncedLyricsNotifier() { - load(); - } - +class SyncedLyricsNotifier extends FamilyAsyncNotifier { Track get _track => arg!; Future getSpotifyLyrics(String? token) async { @@ -128,12 +123,25 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier @override FutureOr build(track) async { try { + final database = ref.watch(databaseProvider); final spotify = ref.watch(spotifyProvider); + if (track == null) { throw "No track currently"; } + + final cachedLyrics = await (database.select(database.lyricsTable) + ..where((tbl) => tbl.trackId.equals(track.id!))) + .map((row) => row.data) + .getSingleOrNull(); + + SubtitleSimple? lyrics = cachedLyrics; + final token = await spotify.getCredentials(); - SubtitleSimple lyrics = await getSpotifyLyrics(token.accessToken); + + if (lyrics == null || lyrics.lyrics.isEmpty) { + lyrics = await getSpotifyLyrics(token.accessToken); + } if (lyrics.lyrics.isEmpty || lyrics.lyrics.length <= 5) { lyrics = await getLRCLibLyrics(); @@ -143,19 +151,21 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier throw Exception("Unable to find lyrics"); } + if (cachedLyrics == null || cachedLyrics.lyrics.isEmpty) { + await database.into(database.lyricsTable).insertOnConflictUpdate( + LyricsTableCompanion.insert( + trackId: track.id!, + data: lyrics, + ), + ); + } + return lyrics; } catch (e, stackTrace) { AppLogger.reportError(e, stackTrace); rethrow; } } - - @override - FutureOr fromJson(Map json) => - SubtitleSimple.fromJson(json.castKeyDeep()); - - @override - Map toJson(SubtitleSimple data) => data.toJson(); } final syncedLyricsDelayProvider = StateProvider((ref) => 0); diff --git a/lib/provider/spotify/playlist/liked.dart b/lib/provider/spotify/playlist/liked.dart index 52463d3d..27c3e2b6 100644 --- a/lib/provider/spotify/playlist/liked.dart +++ b/lib/provider/spotify/playlist/liked.dart @@ -1,10 +1,6 @@ part of '../spotify.dart'; -class LikedTracksNotifier extends AsyncNotifier> with Persistence { - LikedTracksNotifier() { - load(); - } - +class LikedTracksNotifier extends AsyncNotifier> { @override FutureOr> build() async { final spotify = ref.watch(spotifyProvider); @@ -29,18 +25,6 @@ class LikedTracksNotifier extends AsyncNotifier> with Persistence { } }); } - - @override - FutureOr> fromJson(Map json) { - return (json['tracks'] as List).map((e) => Track.fromJson(e)).toList(); - } - - @override - Map toJson(List data) { - return { - 'tracks': data.map((e) => e.toJson()).toList(), - }; - } } final likedTracksProvider = diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index e592e93b..63a8ed38 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -2,6 +2,9 @@ library spotify; import 'dart:async'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/spotify/utils/json_cast.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; @@ -15,7 +18,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; // ignore: depend_on_referenced_packages, implementation_imports import 'package:riverpod/src/async_notifier.dart'; import 'package:spotube/extensions/album_simple.dart'; -import 'package:spotube/extensions/map.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/lyrics.dart'; import 'package:spotube/models/spotify/recommendation_seeds.dart'; @@ -25,7 +27,6 @@ import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/dio/dio.dart'; import 'package:spotube/services/wikipedia/wikipedia.dart'; -import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:wikipedia_api/wikipedia_api.dart'; diff --git a/lib/provider/spotify/utils/json_cast.dart b/lib/provider/spotify/utils/json_cast.dart new file mode 100644 index 00000000..30700971 --- /dev/null +++ b/lib/provider/spotify/utils/json_cast.dart @@ -0,0 +1,21 @@ +Map castNestedJson(Map map) { + return Map.castFrom( + map.map((key, value) { + if (value is Map) { + return MapEntry( + key, + castNestedJson(value), + ); + } else if (value is Iterable) { + return MapEntry( + key, + value.map((e) { + if (e is Map) return castNestedJson(e); + return e; + }).toList(), + ); + } + return MapEntry(key, value); + }), + ); +} diff --git a/lib/provider/spotify/utils/persistence.dart b/lib/provider/spotify/utils/persistence.dart index 14d3c940..57f41dec 100644 --- a/lib/provider/spotify/utils/persistence.dart +++ b/lib/provider/spotify/utils/persistence.dart @@ -16,7 +16,7 @@ mixin Persistence on BuildlessAsyncNotifier { (json is List && json.isNotEmpty)) { state = AsyncData( await fromJson( - PersistedStateNotifier.castNestedJson(json), + castNestedJson(json), ), ); } diff --git a/lib/utils/persisted_change_notifier.dart b/lib/utils/persisted_change_notifier.dart deleted file mode 100644 index d48cb67a..00000000 --- a/lib/utils/persisted_change_notifier.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -abstract class PersistedChangeNotifier extends ChangeNotifier { - late SharedPreferences _localStorage; - PersistedChangeNotifier() { - SharedPreferences.getInstance().then((value) => _localStorage = value).then( - (_) async { - final persistedMap = (await toMap()) - .entries - .toList() - .fold>({}, (acc, entry) { - if (entry.value != null) { - if (entry.value is bool) { - acc[entry.key] = _localStorage.getBool(entry.key); - } else if (entry.value is int) { - acc[entry.key] = _localStorage.getInt(entry.key); - } else if (entry.value is double) { - acc[entry.key] = _localStorage.getDouble(entry.key); - } else if (entry.value is String) { - acc[entry.key] = _localStorage.getString(entry.key); - } - } else { - acc[entry.key] = _localStorage.get(entry.key); - } - return acc; - }); - await loadFromLocal(persistedMap); - notifyListeners(); - }, - ); - } - - FutureOr loadFromLocal(Map map); - - FutureOr> toMap(); - - Future updatePersistence({bool clearNullEntries = false}) async { - for (final entry in (await toMap()).entries) { - if (entry.value is bool) { - await _localStorage.setBool(entry.key, entry.value); - } else if (entry.value is int) { - await _localStorage.setInt(entry.key, entry.value); - } else if (entry.value is double) { - await _localStorage.setDouble(entry.key, entry.value); - } else if (entry.value is String) { - await _localStorage.setString(entry.key, entry.value); - } else if (entry.value == null && clearNullEntries) { - _localStorage.remove(entry.key); - } - } - } -} diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/persisted_state_notifier.dart deleted file mode 100644 index 450bb664..00000000 --- a/lib/utils/persisted_state_notifier.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:hive/hive.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:spotube/components/dialogs/prompt_dialog.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/utils/platform.dart'; -import 'package:spotube/utils/primitive_utils.dart'; - -const secureStorage = FlutterSecureStorage( - aOptions: AndroidOptions( - encryptedSharedPreferences: true, - ), -); - -const kKeyBoxName = "spotube_box_name"; -const kNoEncryptionWarningShownKey = "showedNoEncryptionWarning"; -const kIsUsingEncryption = "isUsingEncryption"; -String getBoxKey(String boxName) => "spotube_box_$boxName"; - -abstract class PersistedStateNotifier extends StateNotifier { - final String cacheKey; - final bool encrypted; - - FutureOr onInit() {} - - PersistedStateNotifier( - super.state, - this.cacheKey, { - this.encrypted = false, - }) { - _load().then((_) => onInit()); - } - - static late LazyBox _box; - static late LazyBox _encryptedBox; - - static Future showNoEncryptionDialog(BuildContext context) async { - final localStorage = await SharedPreferences.getInstance(); - final wasShownAlready = - localStorage.getBool(kNoEncryptionWarningShownKey) == true; - - if (wasShownAlready || !context.mounted) { - return; - } - - await showPromptDialog( - context: context, - title: context.l10n.failed_to_encrypt, - message: context.l10n.encryption_failed_warning, - cancelText: null, - ); - await localStorage.setBool(kNoEncryptionWarningShownKey, true); - } - - static Future read(String key) async { - final localStorage = await SharedPreferences.getInstance(); - if (kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak)) { - return localStorage.getString(key); - } - - try { - await localStorage.setBool(kIsUsingEncryption, true); - return await secureStorage.read(key: key); - } catch (e) { - await localStorage.setBool(kIsUsingEncryption, false); - return localStorage.getString(key); - } - } - - static Future write(String key, String value) async { - final localStorage = await SharedPreferences.getInstance(); - if (kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak)) { - await localStorage.setString(key, value); - return; - } - - try { - await localStorage.setBool(kIsUsingEncryption, true); - await secureStorage.write(key: key, value: value); - } catch (e) { - await localStorage.setBool(kIsUsingEncryption, false); - await localStorage.setString(key, value); - } - } - - static Future initializeBoxes({required String? path}) async { - String? boxName = await read(kKeyBoxName); - - if (boxName == null) { - boxName = "spotube-${PrimitiveUtils.uuid.v4()}"; - await write(kKeyBoxName, boxName); - } - - String? encryptionKey = await read(getBoxKey(boxName)); - - if (encryptionKey == null) { - encryptionKey = base64Url.encode(Hive.generateSecureKey()); - await write(getBoxKey(boxName), encryptionKey); - } - - _encryptedBox = await Hive.openLazyBox( - boxName, - encryptionCipher: HiveAesCipher(base64Url.decode(encryptionKey)), - ); - - _box = await Hive.openLazyBox( - "spotube_cache", - path: path, - ); - } - - LazyBox get box => encrypted ? _encryptedBox : _box; - - Future _load() async { - final json = await box.get(cacheKey); - - if (json != null || - (json is Map && json.entries.isNotEmpty) || - (json is List && json.isNotEmpty)) { - state = await fromJson(castNestedJson(json)); - } - } - - static Map castNestedJson(Map map) { - return Map.castFrom( - map.map((key, value) { - if (value is Map) { - return MapEntry( - key, - castNestedJson(value), - ); - } else if (value is Iterable) { - return MapEntry( - key, - value.map((e) { - if (e is Map) return castNestedJson(e); - return e; - }).toList(), - ); - } - return MapEntry(key, value); - }), - ); - } - - void save() async { - await box.put(cacheKey, toJson()); - } - - FutureOr fromJson(Map json); - Map toJson(); - - @override - set state(T value) { - if (state == value) return; - super.state = value; - save(); - } -} From a3021e4c52b24c5a6870eeb89fa00e3f8bd51636 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 30 Jun 2024 14:14:02 +0600 Subject: [PATCH 156/261] chore: removed unused files --- lib/collections/cache_keys.dart | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 lib/collections/cache_keys.dart diff --git a/lib/collections/cache_keys.dart b/lib/collections/cache_keys.dart deleted file mode 100644 index bca13322..00000000 --- a/lib/collections/cache_keys.dart +++ /dev/null @@ -1,21 +0,0 @@ -abstract class LocalStorageKeys { - static String saveTrackLyrics = 'save_track_lyrics'; - static String recommendationMarket = 'recommendation_market'; - static String ytSearchFormate = 'youtube_search_format'; - - static String clientId = 'clientId'; - static String clientSecret = 'clientSecret'; - static String accessToken = 'accessToken'; - static String refreshToken = 'refreshToken'; - static String expiration = "expiration"; - static String geniusAccessToken = "genius_access_token"; - - static String themeMode = "theme_mode"; - static String nextTrackHotKey = "next_track_hot_key"; - static String prevTrackHotKey = "prev_track_hot_key"; - static String playPauseHotKey = "play_pause_hot_key"; - - static String volume = "volume"; - - static String windowSizeInfo = "window_size_info"; -} From ffb3a3377fe84947213b11aed100d2dd049bd13f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 30 Jun 2024 15:44:24 +0600 Subject: [PATCH 157/261] chore: add migration script to migrate hive to drift --- lib/main.dart | 16 +- lib/modules/stats/top/top.dart | 2 +- lib/pages/stats/albums/albums.dart | 2 +- lib/pages/stats/artists/artists.dart | 2 +- lib/pages/stats/fees/fees.dart | 2 +- lib/pages/stats/minutes/minutes.dart | 2 +- lib/pages/stats/playlists/playlists.dart | 2 +- lib/pages/stats/streams/streams.dart | 2 +- lib/provider/history/state.dart | 35 - lib/provider/history/state.freezed.dart | 644 -------- lib/provider/history/state.g.dart | 55 - lib/provider/history/top.dart | 10 +- lib/services/kv_store/encrypted_kv_store.dart | 2 + lib/services/kv_store/kv_store.dart | 5 + lib/utils/migrations/adapters.dart | 320 ++++ lib/utils/migrations/adapters.freezed.dart | 1380 +++++++++++++++++ lib/utils/migrations/adapters.g.dart | 600 +++++++ lib/utils/migrations/cache_box.dart | 100 ++ lib/utils/migrations/hive.dart | 316 ++++ 19 files changed, 2754 insertions(+), 743 deletions(-) delete mode 100644 lib/provider/history/state.dart delete mode 100644 lib/provider/history/state.freezed.dart delete mode 100644 lib/provider/history/state.g.dart create mode 100644 lib/utils/migrations/adapters.dart create mode 100644 lib/utils/migrations/adapters.freezed.dart create mode 100644 lib/utils/migrations/adapters.g.dart create mode 100644 lib/utils/migrations/cache_box.dart create mode 100644 lib/utils/migrations/hive.dart diff --git a/lib/main.dart b/lib/main.dart index 69c89062..cb553115 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,7 +18,9 @@ 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/models/database/database.dart'; import 'package:spotube/provider/audio_player/audio_player_streams.dart'; +import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/server/bonsoir.dart'; import 'package:spotube/provider/server/server.dart'; import 'package:spotube/provider/tray_manager/tray_manager.dart'; @@ -33,6 +35,7 @@ import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:spotube/themes/theme.dart'; +import 'package:spotube/utils/migrations/hive.dart'; import 'package:spotube/utils/platform.dart'; import 'package:system_theme/system_theme.dart'; import 'package:path_provider/path_provider.dart'; @@ -84,12 +87,23 @@ Future main(List rawArgs) async { Hive.init(hiveCacheDir); + final database = AppDatabase(); + + await migrateFromHiveToDrift(database); + if (kIsDesktop) { await localNotifier.setup(appName: "Spotube"); await WindowManagerTools.initialize(); } - runApp(const ProviderScope(child: Spotube())); + runApp( + ProviderScope( + overrides: [ + databaseProvider.overrideWith((ref) => database), + ], + child: const Spotube(), + ), + ); }); } diff --git a/lib/modules/stats/top/top.dart b/lib/modules/stats/top/top.dart index 52529f3f..ea52c517 100644 --- a/lib/modules/stats/top/top.dart +++ b/lib/modules/stats/top/top.dart @@ -5,7 +5,7 @@ import 'package:spotube/components/themed_button_tab_bar.dart'; import 'package:spotube/modules/stats/top/albums.dart'; import 'package:spotube/modules/stats/top/artists.dart'; import 'package:spotube/modules/stats/top/tracks.dart'; -import 'package:spotube/provider/history/state.dart'; + import 'package:spotube/provider/history/top.dart'; class StatsPageTopSection extends HookConsumerWidget { diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index a13e500b..859eaf26 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/album_item.dart'; -import 'package:spotube/provider/history/state.dart'; + import 'package:spotube/provider/history/top.dart'; class StatsAlbumsPage extends HookConsumerWidget { diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart index 9ebdbe5d..e6dadd95 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; -import 'package:spotube/provider/history/state.dart'; + import 'package:spotube/provider/history/top.dart'; class StatsArtistsPage extends HookConsumerWidget { diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index e881ec70..e1d701eb 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -4,7 +4,7 @@ import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; -import 'package:spotube/provider/history/state.dart'; + import 'package:spotube/provider/history/top.dart'; class StatsStreamFeesPage extends HookConsumerWidget { diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index 1d6a5844..587e9007 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; -import 'package:spotube/provider/history/state.dart'; + import 'package:spotube/provider/history/top.dart'; class StatsMinutesPage extends HookConsumerWidget { diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index 94f8ce9d..f5ee62d0 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/playlist_item.dart'; -import 'package:spotube/provider/history/state.dart'; + import 'package:spotube/provider/history/top.dart'; class StatsPlaylistsPage extends HookConsumerWidget { diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index 41f2d33a..20e8ff96 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; -import 'package:spotube/provider/history/state.dart'; + import 'package:spotube/provider/history/top.dart'; class StatsStreamsPage extends HookConsumerWidget { diff --git a/lib/provider/history/state.dart b/lib/provider/history/state.dart deleted file mode 100644 index 67658502..00000000 --- a/lib/provider/history/state.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:spotify/spotify.dart'; - -part 'state.freezed.dart'; -part 'state.g.dart'; - -enum HistoryDuration { - allTime, - days7, - days30, - months6, - year, - years2, -} - -@freezed -class PlaybackHistoryItem with _$PlaybackHistoryItem { - factory PlaybackHistoryItem.playlist({ - required DateTime date, - required PlaylistSimple playlist, - }) = PlaybackHistoryPlaylist; - - factory PlaybackHistoryItem.album({ - required DateTime date, - required AlbumSimple album, - }) = PlaybackHistoryAlbum; - - factory PlaybackHistoryItem.track({ - required DateTime date, - required Track track, - }) = PlaybackHistoryTrack; - - factory PlaybackHistoryItem.fromJson(Map json) => - _$PlaybackHistoryItemFromJson(json); -} diff --git a/lib/provider/history/state.freezed.dart b/lib/provider/history/state.freezed.dart deleted file mode 100644 index e2ee9421..00000000 --- a/lib/provider/history/state.freezed.dart +++ /dev/null @@ -1,644 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'state.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -PlaybackHistoryItem _$PlaybackHistoryItemFromJson(Map json) { - switch (json['runtimeType']) { - case 'playlist': - return PlaybackHistoryPlaylist.fromJson(json); - case 'album': - return PlaybackHistoryAlbum.fromJson(json); - case 'track': - return PlaybackHistoryTrack.fromJson(json); - - default: - throw CheckedFromJsonException(json, 'runtimeType', 'PlaybackHistoryItem', - 'Invalid union type "${json['runtimeType']}"!'); - } -} - -/// @nodoc -mixin _$PlaybackHistoryItem { - DateTime get date => throw _privateConstructorUsedError; - @optionalTypeArgs - TResult when({ - required TResult Function(DateTime date, PlaylistSimple playlist) playlist, - required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, Track track) track, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, Track track)? track, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, Track track)? track, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult map({ - required TResult Function(PlaybackHistoryPlaylist value) playlist, - required TResult Function(PlaybackHistoryAlbum value) album, - required TResult Function(PlaybackHistoryTrack value) track, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(PlaybackHistoryPlaylist value)? playlist, - TResult? Function(PlaybackHistoryAlbum value)? album, - TResult? Function(PlaybackHistoryTrack value)? track, - }) => - throw _privateConstructorUsedError; - @optionalTypeArgs - TResult maybeMap({ - TResult Function(PlaybackHistoryPlaylist value)? playlist, - TResult Function(PlaybackHistoryAlbum value)? album, - TResult Function(PlaybackHistoryTrack value)? track, - required TResult orElse(), - }) => - throw _privateConstructorUsedError; - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $PlaybackHistoryItemCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $PlaybackHistoryItemCopyWith<$Res> { - factory $PlaybackHistoryItemCopyWith( - PlaybackHistoryItem value, $Res Function(PlaybackHistoryItem) then) = - _$PlaybackHistoryItemCopyWithImpl<$Res, PlaybackHistoryItem>; - @useResult - $Res call({DateTime date}); -} - -/// @nodoc -class _$PlaybackHistoryItemCopyWithImpl<$Res, $Val extends PlaybackHistoryItem> - implements $PlaybackHistoryItemCopyWith<$Res> { - _$PlaybackHistoryItemCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? date = null, - }) { - return _then(_value.copyWith( - date: null == date - ? _value.date - : date // ignore: cast_nullable_to_non_nullable - as DateTime, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$PlaybackHistoryPlaylistImplCopyWith<$Res> - implements $PlaybackHistoryItemCopyWith<$Res> { - factory _$$PlaybackHistoryPlaylistImplCopyWith( - _$PlaybackHistoryPlaylistImpl value, - $Res Function(_$PlaybackHistoryPlaylistImpl) then) = - __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({DateTime date, PlaylistSimple playlist}); -} - -/// @nodoc -class __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res> - extends _$PlaybackHistoryItemCopyWithImpl<$Res, - _$PlaybackHistoryPlaylistImpl> - implements _$$PlaybackHistoryPlaylistImplCopyWith<$Res> { - __$$PlaybackHistoryPlaylistImplCopyWithImpl( - _$PlaybackHistoryPlaylistImpl _value, - $Res Function(_$PlaybackHistoryPlaylistImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? date = null, - Object? playlist = null, - }) { - return _then(_$PlaybackHistoryPlaylistImpl( - date: null == date - ? _value.date - : date // ignore: cast_nullable_to_non_nullable - as DateTime, - playlist: null == playlist - ? _value.playlist - : playlist // ignore: cast_nullable_to_non_nullable - as PlaylistSimple, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist { - _$PlaybackHistoryPlaylistImpl( - {required this.date, required this.playlist, final String? $type}) - : $type = $type ?? 'playlist'; - - factory _$PlaybackHistoryPlaylistImpl.fromJson(Map json) => - _$$PlaybackHistoryPlaylistImplFromJson(json); - - @override - final DateTime date; - @override - final PlaylistSimple playlist; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'PlaybackHistoryItem.playlist(date: $date, playlist: $playlist)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$PlaybackHistoryPlaylistImpl && - (identical(other.date, date) || other.date == date) && - (identical(other.playlist, playlist) || - other.playlist == playlist)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, date, playlist); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> - get copyWith => __$$PlaybackHistoryPlaylistImplCopyWithImpl< - _$PlaybackHistoryPlaylistImpl>(this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(DateTime date, PlaylistSimple playlist) playlist, - required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, Track track) track, - }) { - return playlist(date, this.playlist); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, Track track)? track, - }) { - return playlist?.call(date, this.playlist); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, Track track)? track, - required TResult orElse(), - }) { - if (playlist != null) { - return playlist(date, this.playlist); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(PlaybackHistoryPlaylist value) playlist, - required TResult Function(PlaybackHistoryAlbum value) album, - required TResult Function(PlaybackHistoryTrack value) track, - }) { - return playlist(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(PlaybackHistoryPlaylist value)? playlist, - TResult? Function(PlaybackHistoryAlbum value)? album, - TResult? Function(PlaybackHistoryTrack value)? track, - }) { - return playlist?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(PlaybackHistoryPlaylist value)? playlist, - TResult Function(PlaybackHistoryAlbum value)? album, - TResult Function(PlaybackHistoryTrack value)? track, - required TResult orElse(), - }) { - if (playlist != null) { - return playlist(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$PlaybackHistoryPlaylistImplToJson( - this, - ); - } -} - -abstract class PlaybackHistoryPlaylist implements PlaybackHistoryItem { - factory PlaybackHistoryPlaylist( - {required final DateTime date, - required final PlaylistSimple playlist}) = _$PlaybackHistoryPlaylistImpl; - - factory PlaybackHistoryPlaylist.fromJson(Map json) = - _$PlaybackHistoryPlaylistImpl.fromJson; - - @override - DateTime get date; - PlaylistSimple get playlist; - @override - @JsonKey(ignore: true) - _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class _$$PlaybackHistoryAlbumImplCopyWith<$Res> - implements $PlaybackHistoryItemCopyWith<$Res> { - factory _$$PlaybackHistoryAlbumImplCopyWith(_$PlaybackHistoryAlbumImpl value, - $Res Function(_$PlaybackHistoryAlbumImpl) then) = - __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({DateTime date, AlbumSimple album}); -} - -/// @nodoc -class __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res> - extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryAlbumImpl> - implements _$$PlaybackHistoryAlbumImplCopyWith<$Res> { - __$$PlaybackHistoryAlbumImplCopyWithImpl(_$PlaybackHistoryAlbumImpl _value, - $Res Function(_$PlaybackHistoryAlbumImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? date = null, - Object? album = null, - }) { - return _then(_$PlaybackHistoryAlbumImpl( - date: null == date - ? _value.date - : date // ignore: cast_nullable_to_non_nullable - as DateTime, - album: null == album - ? _value.album - : album // ignore: cast_nullable_to_non_nullable - as AlbumSimple, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum { - _$PlaybackHistoryAlbumImpl( - {required this.date, required this.album, final String? $type}) - : $type = $type ?? 'album'; - - factory _$PlaybackHistoryAlbumImpl.fromJson(Map json) => - _$$PlaybackHistoryAlbumImplFromJson(json); - - @override - final DateTime date; - @override - final AlbumSimple album; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'PlaybackHistoryItem.album(date: $date, album: $album)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$PlaybackHistoryAlbumImpl && - (identical(other.date, date) || other.date == date) && - (identical(other.album, album) || other.album == album)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, date, album); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> - get copyWith => - __$$PlaybackHistoryAlbumImplCopyWithImpl<_$PlaybackHistoryAlbumImpl>( - this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(DateTime date, PlaylistSimple playlist) playlist, - required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, Track track) track, - }) { - return album(date, this.album); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, Track track)? track, - }) { - return album?.call(date, this.album); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, Track track)? track, - required TResult orElse(), - }) { - if (album != null) { - return album(date, this.album); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(PlaybackHistoryPlaylist value) playlist, - required TResult Function(PlaybackHistoryAlbum value) album, - required TResult Function(PlaybackHistoryTrack value) track, - }) { - return album(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(PlaybackHistoryPlaylist value)? playlist, - TResult? Function(PlaybackHistoryAlbum value)? album, - TResult? Function(PlaybackHistoryTrack value)? track, - }) { - return album?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(PlaybackHistoryPlaylist value)? playlist, - TResult Function(PlaybackHistoryAlbum value)? album, - TResult Function(PlaybackHistoryTrack value)? track, - required TResult orElse(), - }) { - if (album != null) { - return album(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$PlaybackHistoryAlbumImplToJson( - this, - ); - } -} - -abstract class PlaybackHistoryAlbum implements PlaybackHistoryItem { - factory PlaybackHistoryAlbum( - {required final DateTime date, - required final AlbumSimple album}) = _$PlaybackHistoryAlbumImpl; - - factory PlaybackHistoryAlbum.fromJson(Map json) = - _$PlaybackHistoryAlbumImpl.fromJson; - - @override - DateTime get date; - AlbumSimple get album; - @override - @JsonKey(ignore: true) - _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> - get copyWith => throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class _$$PlaybackHistoryTrackImplCopyWith<$Res> - implements $PlaybackHistoryItemCopyWith<$Res> { - factory _$$PlaybackHistoryTrackImplCopyWith(_$PlaybackHistoryTrackImpl value, - $Res Function(_$PlaybackHistoryTrackImpl) then) = - __$$PlaybackHistoryTrackImplCopyWithImpl<$Res>; - @override - @useResult - $Res call({DateTime date, Track track}); -} - -/// @nodoc -class __$$PlaybackHistoryTrackImplCopyWithImpl<$Res> - extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryTrackImpl> - implements _$$PlaybackHistoryTrackImplCopyWith<$Res> { - __$$PlaybackHistoryTrackImplCopyWithImpl(_$PlaybackHistoryTrackImpl _value, - $Res Function(_$PlaybackHistoryTrackImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? date = null, - Object? track = null, - }) { - return _then(_$PlaybackHistoryTrackImpl( - date: null == date - ? _value.date - : date // ignore: cast_nullable_to_non_nullable - as DateTime, - track: null == track - ? _value.track - : track // ignore: cast_nullable_to_non_nullable - as Track, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { - _$PlaybackHistoryTrackImpl( - {required this.date, required this.track, final String? $type}) - : $type = $type ?? 'track'; - - factory _$PlaybackHistoryTrackImpl.fromJson(Map json) => - _$$PlaybackHistoryTrackImplFromJson(json); - - @override - final DateTime date; - @override - final Track track; - - @JsonKey(name: 'runtimeType') - final String $type; - - @override - String toString() { - return 'PlaybackHistoryItem.track(date: $date, track: $track)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$PlaybackHistoryTrackImpl && - (identical(other.date, date) || other.date == date) && - (identical(other.track, track) || other.track == track)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, date, track); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> - get copyWith => - __$$PlaybackHistoryTrackImplCopyWithImpl<_$PlaybackHistoryTrackImpl>( - this, _$identity); - - @override - @optionalTypeArgs - TResult when({ - required TResult Function(DateTime date, PlaylistSimple playlist) playlist, - required TResult Function(DateTime date, AlbumSimple album) album, - required TResult Function(DateTime date, Track track) track, - }) { - return track(date, this.track); - } - - @override - @optionalTypeArgs - TResult? whenOrNull({ - TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult? Function(DateTime date, AlbumSimple album)? album, - TResult? Function(DateTime date, Track track)? track, - }) { - return track?.call(date, this.track); - } - - @override - @optionalTypeArgs - TResult maybeWhen({ - TResult Function(DateTime date, PlaylistSimple playlist)? playlist, - TResult Function(DateTime date, AlbumSimple album)? album, - TResult Function(DateTime date, Track track)? track, - required TResult orElse(), - }) { - if (track != null) { - return track(date, this.track); - } - return orElse(); - } - - @override - @optionalTypeArgs - TResult map({ - required TResult Function(PlaybackHistoryPlaylist value) playlist, - required TResult Function(PlaybackHistoryAlbum value) album, - required TResult Function(PlaybackHistoryTrack value) track, - }) { - return track(this); - } - - @override - @optionalTypeArgs - TResult? mapOrNull({ - TResult? Function(PlaybackHistoryPlaylist value)? playlist, - TResult? Function(PlaybackHistoryAlbum value)? album, - TResult? Function(PlaybackHistoryTrack value)? track, - }) { - return track?.call(this); - } - - @override - @optionalTypeArgs - TResult maybeMap({ - TResult Function(PlaybackHistoryPlaylist value)? playlist, - TResult Function(PlaybackHistoryAlbum value)? album, - TResult Function(PlaybackHistoryTrack value)? track, - required TResult orElse(), - }) { - if (track != null) { - return track(this); - } - return orElse(); - } - - @override - Map toJson() { - return _$$PlaybackHistoryTrackImplToJson( - this, - ); - } -} - -abstract class PlaybackHistoryTrack implements PlaybackHistoryItem { - factory PlaybackHistoryTrack( - {required final DateTime date, - required final Track track}) = _$PlaybackHistoryTrackImpl; - - factory PlaybackHistoryTrack.fromJson(Map json) = - _$PlaybackHistoryTrackImpl.fromJson; - - @override - DateTime get date; - Track get track; - @override - @JsonKey(ignore: true) - _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> - get copyWith => throw _privateConstructorUsedError; -} diff --git a/lib/provider/history/state.g.dart b/lib/provider/history/state.g.dart deleted file mode 100644 index dfd01c2c..00000000 --- a/lib/provider/history/state.g.dart +++ /dev/null @@ -1,55 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'state.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -_$PlaybackHistoryPlaylistImpl _$$PlaybackHistoryPlaylistImplFromJson( - Map json) => - _$PlaybackHistoryPlaylistImpl( - date: DateTime.parse(json['date'] as String), - playlist: PlaylistSimple.fromJson( - Map.from(json['playlist'] as Map)), - $type: json['runtimeType'] as String?, - ); - -Map _$$PlaybackHistoryPlaylistImplToJson( - _$PlaybackHistoryPlaylistImpl instance) => - { - 'date': instance.date.toIso8601String(), - 'playlist': instance.playlist.toJson(), - 'runtimeType': instance.$type, - }; - -_$PlaybackHistoryAlbumImpl _$$PlaybackHistoryAlbumImplFromJson(Map json) => - _$PlaybackHistoryAlbumImpl( - date: DateTime.parse(json['date'] as String), - album: - AlbumSimple.fromJson(Map.from(json['album'] as Map)), - $type: json['runtimeType'] as String?, - ); - -Map _$$PlaybackHistoryAlbumImplToJson( - _$PlaybackHistoryAlbumImpl instance) => - { - 'date': instance.date.toIso8601String(), - 'album': instance.album.toJson(), - 'runtimeType': instance.$type, - }; - -_$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) => - _$PlaybackHistoryTrackImpl( - date: DateTime.parse(json['date'] as String), - track: Track.fromJson(Map.from(json['track'] as Map)), - $type: json['runtimeType'] as String?, - ); - -Map _$$PlaybackHistoryTrackImplToJson( - _$PlaybackHistoryTrackImpl instance) => - { - 'date': instance.date.toIso8601String(), - 'track': instance.track.toJson(), - 'runtimeType': instance.$type, - }; diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart index aa12c9b3..965fb3ad 100644 --- a/lib/provider/history/top.dart +++ b/lib/provider/history/top.dart @@ -6,7 +6,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/database/database.dart'; -import 'package:spotube/provider/history/state.dart'; + +enum HistoryDuration { + allTime, + days7, + days30, + months6, + year, + years2, +} final playbackHistoryTopDurationProvider = StateProvider((ref) => HistoryDuration.days30); diff --git a/lib/services/kv_store/encrypted_kv_store.dart b/lib/services/kv_store/encrypted_kv_store.dart index ab4a750e..4eca0007 100644 --- a/lib/services/kv_store/encrypted_kv_store.dart +++ b/lib/services/kv_store/encrypted_kv_store.dart @@ -10,6 +10,8 @@ abstract class EncryptedKvStoreService { ), ); + static FlutterSecureStorage get storage => _storage; + static String? _encryptionKeySync; static Future initialize() async { diff --git a/lib/services/kv_store/kv_store.dart b/lib/services/kv_store/kv_store.dart index 2707ea4d..efe83abf 100644 --- a/lib/services/kv_store/kv_store.dart +++ b/lib/services/kv_store/kv_store.dart @@ -82,4 +82,9 @@ abstract class KVStoreService { static double get volume => sharedPreferences.getDouble('volume') ?? 1.0; static Future setVolume(double value) async => await sharedPreferences.setDouble('volume', value); + + static bool get hasMigratedToDrift => + sharedPreferences.getBool('hasMigratedToDrift') ?? false; + static Future setHasMigratedToDrift(bool value) async => + await sharedPreferences.setBool('hasMigratedToDrift', value); } diff --git a/lib/utils/migrations/adapters.dart b/lib/utils/migrations/adapters.dart new file mode 100644 index 00000000..f7f6350b --- /dev/null +++ b/lib/utils/migrations/adapters.dart @@ -0,0 +1,320 @@ +import 'package:hive/hive.dart'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/services/sourced_track/enums.dart'; + +part 'adapters.g.dart'; +part 'adapters.freezed.dart'; + +@HiveType(typeId: 2) +class SkipSegment { + @HiveField(0) + final int start; + @HiveField(1) + final int end; + SkipSegment(this.start, this.end); + + static String version = 'v1'; + static final boxName = "oss.krtirtho.spotube.skip_segments.$version"; + static LazyBox get box => Hive.lazyBox(boxName); + + SkipSegment.fromJson(Map json) + : start = json['start'], + end = json['end']; + + Map toJson() => { + 'start': start, + 'end': end, + }; +} + +@JsonEnum() +@HiveType(typeId: 5) +enum SourceType { + @HiveField(0) + youtube._("YouTube"), + + @HiveField(1) + youtubeMusic._("YouTube Music"), + + @HiveField(2) + jiosaavn._("JioSaavn"); + + final String label; + + const SourceType._(this.label); +} + +@JsonSerializable() +@HiveType(typeId: 6) +class SourceMatch { + @HiveField(0) + String id; + + @HiveField(1) + String sourceId; + + @HiveField(2) + SourceType sourceType; + + @HiveField(3) + DateTime createdAt; + + SourceMatch({ + required this.id, + required this.sourceId, + required this.sourceType, + required this.createdAt, + }); + + factory SourceMatch.fromJson(Map json) => + _$SourceMatchFromJson(json); + + Map toJson() => _$SourceMatchToJson(this); + + static String version = 'v1'; + static final boxName = "oss.krtirtho.spotube.source_matches.$version"; + + static LazyBox get box => Hive.lazyBox(boxName); +} + +@JsonSerializable() +class AuthenticationCredentials { + String cookie; + String accessToken; + DateTime expiration; + + AuthenticationCredentials({ + required this.cookie, + required this.accessToken, + required this.expiration, + }); + + factory AuthenticationCredentials.fromJson(Map json) { + return AuthenticationCredentials( + cookie: json['cookie'] as String, + accessToken: json['accessToken'] as String, + expiration: DateTime.parse(json['expiration'] as String), + ); + } + + Map toJson() { + return { + 'cookie': cookie, + 'accessToken': accessToken, + 'expiration': expiration.toIso8601String(), + }; + } +} + +@JsonEnum() +enum LayoutMode { + compact, + extended, + adaptive, +} + +@JsonEnum() +enum CloseBehavior { + minimizeToTray, + close, +} + +@JsonEnum() +enum AudioSource { + youtube, + piped, + jiosaavn; + + String get label => name[0].toUpperCase() + name.substring(1); +} + +@JsonEnum() +enum MusicCodec { + m4a._("M4a (Best for downloaded music)"), + weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"); + + final String label; + const MusicCodec._(this.label); +} + +@JsonEnum() +enum SearchMode { + youtube._("YouTube"), + youtubeMusic._("YouTube Music"); + + final String label; + + const SearchMode._(this.label); + + factory SearchMode.fromString(String key) { + return SearchMode.values.firstWhere((e) => e.name == key); + } +} + +@freezed +class UserPreferences with _$UserPreferences { + const factory UserPreferences({ + @Default(SourceQualities.high) SourceQualities audioQuality, + @Default(true) bool albumColorSync, + @Default(false) bool amoledDarkTheme, + @Default(true) bool checkUpdate, + @Default(false) bool normalizeAudio, + @Default(false) bool showSystemTrayIcon, + @Default(false) bool skipNonMusic, + @Default(false) bool systemTitleBar, + @Default(CloseBehavior.close) CloseBehavior closeBehavior, + @Default(SpotubeColor(0xFF2196F3, name: "Blue")) + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue, + ) + SpotubeColor accentColorScheme, + @Default(LayoutMode.adaptive) LayoutMode layoutMode, + @Default(Locale("system", "system")) + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue, + ) + Locale locale, + @Default(Market.US) Market recommendationMarket, + @Default(SearchMode.youtube) SearchMode searchMode, + @Default("") String downloadLocation, + @Default([]) List localLibraryLocation, + @Default("https://pipedapi.kavin.rocks") String pipedInstance, + @Default(ThemeMode.system) ThemeMode themeMode, + @Default(AudioSource.youtube) AudioSource audioSource, + @Default(SourceCodecs.weba) SourceCodecs streamMusicCodec, + @Default(SourceCodecs.m4a) SourceCodecs downloadMusicCodec, + @Default(true) bool discordPresence, + @Default(true) bool endlessPlayback, + @Default(false) bool enableConnect, + }) = _UserPreferences; + factory UserPreferences.fromJson(Map json) => + _$UserPreferencesFromJson(json); + + factory UserPreferences.withDefaults() => UserPreferences.fromJson({}); + + static SpotubeColor _accentColorSchemeFromJson(Map json) { + return SpotubeColor.fromString(json["color"]); + } + + static Map? _accentColorSchemeReadValue( + Map json, String key) { + if (json[key] is String) { + return {"color": json[key]}; + } + + return json[key] as Map?; + } + + static Map _accentColorSchemeToJson(SpotubeColor color) { + return {"color": color.toString()}; + } + + static Locale _localeFromJson(Map json) { + return Locale(json["languageCode"], json["countryCode"]); + } + + static Map _localeToJson(Locale locale) { + return { + "languageCode": locale.languageCode, + "countryCode": locale.countryCode, + }; + } + + static Map? _localeReadValue( + Map json, String key) { + if (json[key] is String) { + final map = jsonDecode(json[key]); + return { + "languageCode": map["lc"], + "countryCode": map["cc"], + }; + } + + return json[key] as Map?; + } +} + +enum BlacklistedType { + artist, + track; + + static BlacklistedType fromName(String name) => + BlacklistedType.values.firstWhere((e) => e.name == name); +} + +class BlacklistedElement { + final String id; + final String name; + final BlacklistedType type; + + BlacklistedElement.fromJson(Map json) + : id = json['id'], + name = json['name'], + type = BlacklistedType.fromName(json['type']); + + Map toJson() => {'id': id, 'type': type.name, 'name': name}; +} + +@freezed +class PlaybackHistoryItem with _$PlaybackHistoryItem { + factory PlaybackHistoryItem.playlist({ + required DateTime date, + required PlaylistSimple playlist, + }) = PlaybackHistoryPlaylist; + + factory PlaybackHistoryItem.album({ + required DateTime date, + required AlbumSimple album, + }) = PlaybackHistoryAlbum; + + factory PlaybackHistoryItem.track({ + required DateTime date, + required Track track, + }) = PlaybackHistoryTrack; + + factory PlaybackHistoryItem.fromJson(Map json) => + _$PlaybackHistoryItemFromJson(json); +} + +class PlaybackHistoryState { + final List items; + const PlaybackHistoryState({this.items = const []}); + + factory PlaybackHistoryState.fromJson(Map json) { + return PlaybackHistoryState( + items: json["items"] + ?.map( + (json) => PlaybackHistoryItem.fromJson(json), + ) + .toList() + .cast() ?? + [], + ); + } +} + +class ScrobblerState { + final String username; + final String passwordHash; + + ScrobblerState({ + required this.username, + required this.passwordHash, + }); + + factory ScrobblerState.fromJson(Map json) { + return ScrobblerState( + username: json["username"], + passwordHash: json["passwordHash"], + ); + } +} diff --git a/lib/utils/migrations/adapters.freezed.dart b/lib/utils/migrations/adapters.freezed.dart new file mode 100644 index 00000000..339ec0e5 --- /dev/null +++ b/lib/utils/migrations/adapters.freezed.dart @@ -0,0 +1,1380 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'adapters.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +UserPreferences _$UserPreferencesFromJson(Map json) { + return _UserPreferences.fromJson(json); +} + +/// @nodoc +mixin _$UserPreferences { + SourceQualities get audioQuality => throw _privateConstructorUsedError; + bool get albumColorSync => throw _privateConstructorUsedError; + bool get amoledDarkTheme => throw _privateConstructorUsedError; + bool get checkUpdate => throw _privateConstructorUsedError; + bool get normalizeAudio => throw _privateConstructorUsedError; + bool get showSystemTrayIcon => throw _privateConstructorUsedError; + bool get skipNonMusic => throw _privateConstructorUsedError; + bool get systemTitleBar => throw _privateConstructorUsedError; + CloseBehavior get closeBehavior => throw _privateConstructorUsedError; + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + SpotubeColor get accentColorScheme => throw _privateConstructorUsedError; + LayoutMode get layoutMode => throw _privateConstructorUsedError; + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + Locale get locale => throw _privateConstructorUsedError; + Market get recommendationMarket => throw _privateConstructorUsedError; + SearchMode get searchMode => throw _privateConstructorUsedError; + String get downloadLocation => throw _privateConstructorUsedError; + List get localLibraryLocation => throw _privateConstructorUsedError; + String get pipedInstance => throw _privateConstructorUsedError; + ThemeMode get themeMode => throw _privateConstructorUsedError; + AudioSource get audioSource => throw _privateConstructorUsedError; + SourceCodecs get streamMusicCodec => throw _privateConstructorUsedError; + SourceCodecs get downloadMusicCodec => throw _privateConstructorUsedError; + bool get discordPresence => throw _privateConstructorUsedError; + bool get endlessPlayback => throw _privateConstructorUsedError; + bool get enableConnect => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $UserPreferencesCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $UserPreferencesCopyWith<$Res> { + factory $UserPreferencesCopyWith( + UserPreferences value, $Res Function(UserPreferences) then) = + _$UserPreferencesCopyWithImpl<$Res, UserPreferences>; + @useResult + $Res call( + {SourceQualities audioQuality, + bool albumColorSync, + bool amoledDarkTheme, + bool checkUpdate, + bool normalizeAudio, + bool showSystemTrayIcon, + bool skipNonMusic, + bool systemTitleBar, + CloseBehavior closeBehavior, + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + SpotubeColor accentColorScheme, + LayoutMode layoutMode, + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + Locale locale, + Market recommendationMarket, + SearchMode searchMode, + String downloadLocation, + List localLibraryLocation, + String pipedInstance, + ThemeMode themeMode, + AudioSource audioSource, + SourceCodecs streamMusicCodec, + SourceCodecs downloadMusicCodec, + bool discordPresence, + bool endlessPlayback, + bool enableConnect}); +} + +/// @nodoc +class _$UserPreferencesCopyWithImpl<$Res, $Val extends UserPreferences> + implements $UserPreferencesCopyWith<$Res> { + _$UserPreferencesCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? audioQuality = null, + Object? albumColorSync = null, + Object? amoledDarkTheme = null, + Object? checkUpdate = null, + Object? normalizeAudio = null, + Object? showSystemTrayIcon = null, + Object? skipNonMusic = null, + Object? systemTitleBar = null, + Object? closeBehavior = null, + Object? accentColorScheme = null, + Object? layoutMode = null, + Object? locale = null, + Object? recommendationMarket = null, + Object? searchMode = null, + Object? downloadLocation = null, + Object? localLibraryLocation = null, + Object? pipedInstance = null, + Object? themeMode = null, + Object? audioSource = null, + Object? streamMusicCodec = null, + Object? downloadMusicCodec = null, + Object? discordPresence = null, + Object? endlessPlayback = null, + Object? enableConnect = null, + }) { + return _then(_value.copyWith( + audioQuality: null == audioQuality + ? _value.audioQuality + : audioQuality // ignore: cast_nullable_to_non_nullable + as SourceQualities, + albumColorSync: null == albumColorSync + ? _value.albumColorSync + : albumColorSync // ignore: cast_nullable_to_non_nullable + as bool, + amoledDarkTheme: null == amoledDarkTheme + ? _value.amoledDarkTheme + : amoledDarkTheme // ignore: cast_nullable_to_non_nullable + as bool, + checkUpdate: null == checkUpdate + ? _value.checkUpdate + : checkUpdate // ignore: cast_nullable_to_non_nullable + as bool, + normalizeAudio: null == normalizeAudio + ? _value.normalizeAudio + : normalizeAudio // ignore: cast_nullable_to_non_nullable + as bool, + showSystemTrayIcon: null == showSystemTrayIcon + ? _value.showSystemTrayIcon + : showSystemTrayIcon // ignore: cast_nullable_to_non_nullable + as bool, + skipNonMusic: null == skipNonMusic + ? _value.skipNonMusic + : skipNonMusic // ignore: cast_nullable_to_non_nullable + as bool, + systemTitleBar: null == systemTitleBar + ? _value.systemTitleBar + : systemTitleBar // ignore: cast_nullable_to_non_nullable + as bool, + closeBehavior: null == closeBehavior + ? _value.closeBehavior + : closeBehavior // ignore: cast_nullable_to_non_nullable + as CloseBehavior, + accentColorScheme: null == accentColorScheme + ? _value.accentColorScheme + : accentColorScheme // ignore: cast_nullable_to_non_nullable + as SpotubeColor, + layoutMode: null == layoutMode + ? _value.layoutMode + : layoutMode // ignore: cast_nullable_to_non_nullable + as LayoutMode, + locale: null == locale + ? _value.locale + : locale // ignore: cast_nullable_to_non_nullable + as Locale, + recommendationMarket: null == recommendationMarket + ? _value.recommendationMarket + : recommendationMarket // ignore: cast_nullable_to_non_nullable + as Market, + searchMode: null == searchMode + ? _value.searchMode + : searchMode // ignore: cast_nullable_to_non_nullable + as SearchMode, + downloadLocation: null == downloadLocation + ? _value.downloadLocation + : downloadLocation // ignore: cast_nullable_to_non_nullable + as String, + localLibraryLocation: null == localLibraryLocation + ? _value.localLibraryLocation + : localLibraryLocation // ignore: cast_nullable_to_non_nullable + as List, + pipedInstance: null == pipedInstance + ? _value.pipedInstance + : pipedInstance // ignore: cast_nullable_to_non_nullable + as String, + themeMode: null == themeMode + ? _value.themeMode + : themeMode // ignore: cast_nullable_to_non_nullable + as ThemeMode, + audioSource: null == audioSource + ? _value.audioSource + : audioSource // ignore: cast_nullable_to_non_nullable + as AudioSource, + streamMusicCodec: null == streamMusicCodec + ? _value.streamMusicCodec + : streamMusicCodec // ignore: cast_nullable_to_non_nullable + as SourceCodecs, + downloadMusicCodec: null == downloadMusicCodec + ? _value.downloadMusicCodec + : downloadMusicCodec // ignore: cast_nullable_to_non_nullable + as SourceCodecs, + discordPresence: null == discordPresence + ? _value.discordPresence + : discordPresence // ignore: cast_nullable_to_non_nullable + as bool, + endlessPlayback: null == endlessPlayback + ? _value.endlessPlayback + : endlessPlayback // ignore: cast_nullable_to_non_nullable + as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$UserPreferencesImplCopyWith<$Res> + implements $UserPreferencesCopyWith<$Res> { + factory _$$UserPreferencesImplCopyWith(_$UserPreferencesImpl value, + $Res Function(_$UserPreferencesImpl) then) = + __$$UserPreferencesImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {SourceQualities audioQuality, + bool albumColorSync, + bool amoledDarkTheme, + bool checkUpdate, + bool normalizeAudio, + bool showSystemTrayIcon, + bool skipNonMusic, + bool systemTitleBar, + CloseBehavior closeBehavior, + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + SpotubeColor accentColorScheme, + LayoutMode layoutMode, + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + Locale locale, + Market recommendationMarket, + SearchMode searchMode, + String downloadLocation, + List localLibraryLocation, + String pipedInstance, + ThemeMode themeMode, + AudioSource audioSource, + SourceCodecs streamMusicCodec, + SourceCodecs downloadMusicCodec, + bool discordPresence, + bool endlessPlayback, + bool enableConnect}); +} + +/// @nodoc +class __$$UserPreferencesImplCopyWithImpl<$Res> + extends _$UserPreferencesCopyWithImpl<$Res, _$UserPreferencesImpl> + implements _$$UserPreferencesImplCopyWith<$Res> { + __$$UserPreferencesImplCopyWithImpl( + _$UserPreferencesImpl _value, $Res Function(_$UserPreferencesImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? audioQuality = null, + Object? albumColorSync = null, + Object? amoledDarkTheme = null, + Object? checkUpdate = null, + Object? normalizeAudio = null, + Object? showSystemTrayIcon = null, + Object? skipNonMusic = null, + Object? systemTitleBar = null, + Object? closeBehavior = null, + Object? accentColorScheme = null, + Object? layoutMode = null, + Object? locale = null, + Object? recommendationMarket = null, + Object? searchMode = null, + Object? downloadLocation = null, + Object? localLibraryLocation = null, + Object? pipedInstance = null, + Object? themeMode = null, + Object? audioSource = null, + Object? streamMusicCodec = null, + Object? downloadMusicCodec = null, + Object? discordPresence = null, + Object? endlessPlayback = null, + Object? enableConnect = null, + }) { + return _then(_$UserPreferencesImpl( + audioQuality: null == audioQuality + ? _value.audioQuality + : audioQuality // ignore: cast_nullable_to_non_nullable + as SourceQualities, + albumColorSync: null == albumColorSync + ? _value.albumColorSync + : albumColorSync // ignore: cast_nullable_to_non_nullable + as bool, + amoledDarkTheme: null == amoledDarkTheme + ? _value.amoledDarkTheme + : amoledDarkTheme // ignore: cast_nullable_to_non_nullable + as bool, + checkUpdate: null == checkUpdate + ? _value.checkUpdate + : checkUpdate // ignore: cast_nullable_to_non_nullable + as bool, + normalizeAudio: null == normalizeAudio + ? _value.normalizeAudio + : normalizeAudio // ignore: cast_nullable_to_non_nullable + as bool, + showSystemTrayIcon: null == showSystemTrayIcon + ? _value.showSystemTrayIcon + : showSystemTrayIcon // ignore: cast_nullable_to_non_nullable + as bool, + skipNonMusic: null == skipNonMusic + ? _value.skipNonMusic + : skipNonMusic // ignore: cast_nullable_to_non_nullable + as bool, + systemTitleBar: null == systemTitleBar + ? _value.systemTitleBar + : systemTitleBar // ignore: cast_nullable_to_non_nullable + as bool, + closeBehavior: null == closeBehavior + ? _value.closeBehavior + : closeBehavior // ignore: cast_nullable_to_non_nullable + as CloseBehavior, + accentColorScheme: null == accentColorScheme + ? _value.accentColorScheme + : accentColorScheme // ignore: cast_nullable_to_non_nullable + as SpotubeColor, + layoutMode: null == layoutMode + ? _value.layoutMode + : layoutMode // ignore: cast_nullable_to_non_nullable + as LayoutMode, + locale: null == locale + ? _value.locale + : locale // ignore: cast_nullable_to_non_nullable + as Locale, + recommendationMarket: null == recommendationMarket + ? _value.recommendationMarket + : recommendationMarket // ignore: cast_nullable_to_non_nullable + as Market, + searchMode: null == searchMode + ? _value.searchMode + : searchMode // ignore: cast_nullable_to_non_nullable + as SearchMode, + downloadLocation: null == downloadLocation + ? _value.downloadLocation + : downloadLocation // ignore: cast_nullable_to_non_nullable + as String, + localLibraryLocation: null == localLibraryLocation + ? _value._localLibraryLocation + : localLibraryLocation // ignore: cast_nullable_to_non_nullable + as List, + pipedInstance: null == pipedInstance + ? _value.pipedInstance + : pipedInstance // ignore: cast_nullable_to_non_nullable + as String, + themeMode: null == themeMode + ? _value.themeMode + : themeMode // ignore: cast_nullable_to_non_nullable + as ThemeMode, + audioSource: null == audioSource + ? _value.audioSource + : audioSource // ignore: cast_nullable_to_non_nullable + as AudioSource, + streamMusicCodec: null == streamMusicCodec + ? _value.streamMusicCodec + : streamMusicCodec // ignore: cast_nullable_to_non_nullable + as SourceCodecs, + downloadMusicCodec: null == downloadMusicCodec + ? _value.downloadMusicCodec + : downloadMusicCodec // ignore: cast_nullable_to_non_nullable + as SourceCodecs, + discordPresence: null == discordPresence + ? _value.discordPresence + : discordPresence // ignore: cast_nullable_to_non_nullable + as bool, + endlessPlayback: null == endlessPlayback + ? _value.endlessPlayback + : endlessPlayback // ignore: cast_nullable_to_non_nullable + as bool, + enableConnect: null == enableConnect + ? _value.enableConnect + : enableConnect // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$UserPreferencesImpl implements _UserPreferences { + const _$UserPreferencesImpl( + {this.audioQuality = SourceQualities.high, + this.albumColorSync = true, + this.amoledDarkTheme = false, + this.checkUpdate = true, + this.normalizeAudio = false, + this.showSystemTrayIcon = false, + this.skipNonMusic = false, + this.systemTitleBar = false, + this.closeBehavior = CloseBehavior.close, + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + this.accentColorScheme = const SpotubeColor(0xFF2196F3, name: "Blue"), + this.layoutMode = LayoutMode.adaptive, + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + this.locale = const Locale("system", "system"), + this.recommendationMarket = Market.US, + this.searchMode = SearchMode.youtube, + this.downloadLocation = "", + final List localLibraryLocation = const [], + this.pipedInstance = "https://pipedapi.kavin.rocks", + this.themeMode = ThemeMode.system, + this.audioSource = AudioSource.youtube, + this.streamMusicCodec = SourceCodecs.weba, + this.downloadMusicCodec = SourceCodecs.m4a, + this.discordPresence = true, + this.endlessPlayback = true, + this.enableConnect = false}) + : _localLibraryLocation = localLibraryLocation; + + factory _$UserPreferencesImpl.fromJson(Map json) => + _$$UserPreferencesImplFromJson(json); + + @override + @JsonKey() + final SourceQualities audioQuality; + @override + @JsonKey() + final bool albumColorSync; + @override + @JsonKey() + final bool amoledDarkTheme; + @override + @JsonKey() + final bool checkUpdate; + @override + @JsonKey() + final bool normalizeAudio; + @override + @JsonKey() + final bool showSystemTrayIcon; + @override + @JsonKey() + final bool skipNonMusic; + @override + @JsonKey() + final bool systemTitleBar; + @override + @JsonKey() + final CloseBehavior closeBehavior; + @override + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + final SpotubeColor accentColorScheme; + @override + @JsonKey() + final LayoutMode layoutMode; + @override + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + final Locale locale; + @override + @JsonKey() + final Market recommendationMarket; + @override + @JsonKey() + final SearchMode searchMode; + @override + @JsonKey() + final String downloadLocation; + final List _localLibraryLocation; + @override + @JsonKey() + List get localLibraryLocation { + if (_localLibraryLocation is EqualUnmodifiableListView) + return _localLibraryLocation; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_localLibraryLocation); + } + + @override + @JsonKey() + final String pipedInstance; + @override + @JsonKey() + final ThemeMode themeMode; + @override + @JsonKey() + final AudioSource audioSource; + @override + @JsonKey() + final SourceCodecs streamMusicCodec; + @override + @JsonKey() + final SourceCodecs downloadMusicCodec; + @override + @JsonKey() + final bool discordPresence; + @override + @JsonKey() + final bool endlessPlayback; + @override + @JsonKey() + final bool enableConnect; + + @override + String toString() { + return 'UserPreferences(audioQuality: $audioQuality, albumColorSync: $albumColorSync, amoledDarkTheme: $amoledDarkTheme, checkUpdate: $checkUpdate, normalizeAudio: $normalizeAudio, showSystemTrayIcon: $showSystemTrayIcon, skipNonMusic: $skipNonMusic, systemTitleBar: $systemTitleBar, closeBehavior: $closeBehavior, accentColorScheme: $accentColorScheme, layoutMode: $layoutMode, locale: $locale, recommendationMarket: $recommendationMarket, searchMode: $searchMode, downloadLocation: $downloadLocation, localLibraryLocation: $localLibraryLocation, pipedInstance: $pipedInstance, themeMode: $themeMode, audioSource: $audioSource, streamMusicCodec: $streamMusicCodec, downloadMusicCodec: $downloadMusicCodec, discordPresence: $discordPresence, endlessPlayback: $endlessPlayback, enableConnect: $enableConnect)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$UserPreferencesImpl && + (identical(other.audioQuality, audioQuality) || + other.audioQuality == audioQuality) && + (identical(other.albumColorSync, albumColorSync) || + other.albumColorSync == albumColorSync) && + (identical(other.amoledDarkTheme, amoledDarkTheme) || + other.amoledDarkTheme == amoledDarkTheme) && + (identical(other.checkUpdate, checkUpdate) || + other.checkUpdate == checkUpdate) && + (identical(other.normalizeAudio, normalizeAudio) || + other.normalizeAudio == normalizeAudio) && + (identical(other.showSystemTrayIcon, showSystemTrayIcon) || + other.showSystemTrayIcon == showSystemTrayIcon) && + (identical(other.skipNonMusic, skipNonMusic) || + other.skipNonMusic == skipNonMusic) && + (identical(other.systemTitleBar, systemTitleBar) || + other.systemTitleBar == systemTitleBar) && + (identical(other.closeBehavior, closeBehavior) || + other.closeBehavior == closeBehavior) && + (identical(other.accentColorScheme, accentColorScheme) || + other.accentColorScheme == accentColorScheme) && + (identical(other.layoutMode, layoutMode) || + other.layoutMode == layoutMode) && + (identical(other.locale, locale) || other.locale == locale) && + (identical(other.recommendationMarket, recommendationMarket) || + other.recommendationMarket == recommendationMarket) && + (identical(other.searchMode, searchMode) || + other.searchMode == searchMode) && + (identical(other.downloadLocation, downloadLocation) || + other.downloadLocation == downloadLocation) && + const DeepCollectionEquality() + .equals(other._localLibraryLocation, _localLibraryLocation) && + (identical(other.pipedInstance, pipedInstance) || + other.pipedInstance == pipedInstance) && + (identical(other.themeMode, themeMode) || + other.themeMode == themeMode) && + (identical(other.audioSource, audioSource) || + other.audioSource == audioSource) && + (identical(other.streamMusicCodec, streamMusicCodec) || + other.streamMusicCodec == streamMusicCodec) && + (identical(other.downloadMusicCodec, downloadMusicCodec) || + other.downloadMusicCodec == downloadMusicCodec) && + (identical(other.discordPresence, discordPresence) || + other.discordPresence == discordPresence) && + (identical(other.endlessPlayback, endlessPlayback) || + other.endlessPlayback == endlessPlayback) && + (identical(other.enableConnect, enableConnect) || + other.enableConnect == enableConnect)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hashAll([ + runtimeType, + audioQuality, + albumColorSync, + amoledDarkTheme, + checkUpdate, + normalizeAudio, + showSystemTrayIcon, + skipNonMusic, + systemTitleBar, + closeBehavior, + accentColorScheme, + layoutMode, + locale, + recommendationMarket, + searchMode, + downloadLocation, + const DeepCollectionEquality().hash(_localLibraryLocation), + pipedInstance, + themeMode, + audioSource, + streamMusicCodec, + downloadMusicCodec, + discordPresence, + endlessPlayback, + enableConnect + ]); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => + __$$UserPreferencesImplCopyWithImpl<_$UserPreferencesImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$UserPreferencesImplToJson( + this, + ); + } +} + +abstract class _UserPreferences implements UserPreferences { + const factory _UserPreferences( + {final SourceQualities audioQuality, + final bool albumColorSync, + final bool amoledDarkTheme, + final bool checkUpdate, + final bool normalizeAudio, + final bool showSystemTrayIcon, + final bool skipNonMusic, + final bool systemTitleBar, + final CloseBehavior closeBehavior, + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + final SpotubeColor accentColorScheme, + final LayoutMode layoutMode, + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + final Locale locale, + final Market recommendationMarket, + final SearchMode searchMode, + final String downloadLocation, + final List localLibraryLocation, + final String pipedInstance, + final ThemeMode themeMode, + final AudioSource audioSource, + final SourceCodecs streamMusicCodec, + final SourceCodecs downloadMusicCodec, + final bool discordPresence, + final bool endlessPlayback, + final bool enableConnect}) = _$UserPreferencesImpl; + + factory _UserPreferences.fromJson(Map json) = + _$UserPreferencesImpl.fromJson; + + @override + SourceQualities get audioQuality; + @override + bool get albumColorSync; + @override + bool get amoledDarkTheme; + @override + bool get checkUpdate; + @override + bool get normalizeAudio; + @override + bool get showSystemTrayIcon; + @override + bool get skipNonMusic; + @override + bool get systemTitleBar; + @override + CloseBehavior get closeBehavior; + @override + @JsonKey( + fromJson: UserPreferences._accentColorSchemeFromJson, + toJson: UserPreferences._accentColorSchemeToJson, + readValue: UserPreferences._accentColorSchemeReadValue) + SpotubeColor get accentColorScheme; + @override + LayoutMode get layoutMode; + @override + @JsonKey( + fromJson: UserPreferences._localeFromJson, + toJson: UserPreferences._localeToJson, + readValue: UserPreferences._localeReadValue) + Locale get locale; + @override + Market get recommendationMarket; + @override + SearchMode get searchMode; + @override + String get downloadLocation; + @override + List get localLibraryLocation; + @override + String get pipedInstance; + @override + ThemeMode get themeMode; + @override + AudioSource get audioSource; + @override + SourceCodecs get streamMusicCodec; + @override + SourceCodecs get downloadMusicCodec; + @override + bool get discordPresence; + @override + bool get endlessPlayback; + @override + bool get enableConnect; + @override + @JsonKey(ignore: true) + _$$UserPreferencesImplCopyWith<_$UserPreferencesImpl> get copyWith => + throw _privateConstructorUsedError; +} + +PlaybackHistoryItem _$PlaybackHistoryItemFromJson(Map json) { + switch (json['runtimeType']) { + case 'playlist': + return PlaybackHistoryPlaylist.fromJson(json); + case 'album': + return PlaybackHistoryAlbum.fromJson(json); + case 'track': + return PlaybackHistoryTrack.fromJson(json); + + default: + throw CheckedFromJsonException(json, 'runtimeType', 'PlaybackHistoryItem', + 'Invalid union type "${json['runtimeType']}"!'); + } +} + +/// @nodoc +mixin _$PlaybackHistoryItem { + DateTime get date => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $PlaybackHistoryItemCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PlaybackHistoryItemCopyWith<$Res> { + factory $PlaybackHistoryItemCopyWith( + PlaybackHistoryItem value, $Res Function(PlaybackHistoryItem) then) = + _$PlaybackHistoryItemCopyWithImpl<$Res, PlaybackHistoryItem>; + @useResult + $Res call({DateTime date}); +} + +/// @nodoc +class _$PlaybackHistoryItemCopyWithImpl<$Res, $Val extends PlaybackHistoryItem> + implements $PlaybackHistoryItemCopyWith<$Res> { + _$PlaybackHistoryItemCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + }) { + return _then(_value.copyWith( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$PlaybackHistoryPlaylistImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryPlaylistImplCopyWith( + _$PlaybackHistoryPlaylistImpl value, + $Res Function(_$PlaybackHistoryPlaylistImpl) then) = + __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, PlaylistSimple playlist}); +} + +/// @nodoc +class __$$PlaybackHistoryPlaylistImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, + _$PlaybackHistoryPlaylistImpl> + implements _$$PlaybackHistoryPlaylistImplCopyWith<$Res> { + __$$PlaybackHistoryPlaylistImplCopyWithImpl( + _$PlaybackHistoryPlaylistImpl _value, + $Res Function(_$PlaybackHistoryPlaylistImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? playlist = null, + }) { + return _then(_$PlaybackHistoryPlaylistImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + playlist: null == playlist + ? _value.playlist + : playlist // ignore: cast_nullable_to_non_nullable + as PlaylistSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryPlaylistImpl implements PlaybackHistoryPlaylist { + _$PlaybackHistoryPlaylistImpl( + {required this.date, required this.playlist, final String? $type}) + : $type = $type ?? 'playlist'; + + factory _$PlaybackHistoryPlaylistImpl.fromJson(Map json) => + _$$PlaybackHistoryPlaylistImplFromJson(json); + + @override + final DateTime date; + @override + final PlaylistSimple playlist; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.playlist(date: $date, playlist: $playlist)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryPlaylistImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.playlist, playlist) || + other.playlist == playlist)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, playlist); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> + get copyWith => __$$PlaybackHistoryPlaylistImplCopyWithImpl< + _$PlaybackHistoryPlaylistImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return playlist(date, this.playlist); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return playlist?.call(date, this.playlist); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(date, this.playlist); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return playlist(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return playlist?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (playlist != null) { + return playlist(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryPlaylistImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryPlaylist implements PlaybackHistoryItem { + factory PlaybackHistoryPlaylist( + {required final DateTime date, + required final PlaylistSimple playlist}) = _$PlaybackHistoryPlaylistImpl; + + factory PlaybackHistoryPlaylist.fromJson(Map json) = + _$PlaybackHistoryPlaylistImpl.fromJson; + + @override + DateTime get date; + PlaylistSimple get playlist; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryPlaylistImplCopyWith<_$PlaybackHistoryPlaylistImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PlaybackHistoryAlbumImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryAlbumImplCopyWith(_$PlaybackHistoryAlbumImpl value, + $Res Function(_$PlaybackHistoryAlbumImpl) then) = + __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, AlbumSimple album}); +} + +/// @nodoc +class __$$PlaybackHistoryAlbumImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryAlbumImpl> + implements _$$PlaybackHistoryAlbumImplCopyWith<$Res> { + __$$PlaybackHistoryAlbumImplCopyWithImpl(_$PlaybackHistoryAlbumImpl _value, + $Res Function(_$PlaybackHistoryAlbumImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? album = null, + }) { + return _then(_$PlaybackHistoryAlbumImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + album: null == album + ? _value.album + : album // ignore: cast_nullable_to_non_nullable + as AlbumSimple, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryAlbumImpl implements PlaybackHistoryAlbum { + _$PlaybackHistoryAlbumImpl( + {required this.date, required this.album, final String? $type}) + : $type = $type ?? 'album'; + + factory _$PlaybackHistoryAlbumImpl.fromJson(Map json) => + _$$PlaybackHistoryAlbumImplFromJson(json); + + @override + final DateTime date; + @override + final AlbumSimple album; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.album(date: $date, album: $album)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryAlbumImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.album, album) || other.album == album)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, album); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> + get copyWith => + __$$PlaybackHistoryAlbumImplCopyWithImpl<_$PlaybackHistoryAlbumImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return album(date, this.album); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return album?.call(date, this.album); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (album != null) { + return album(date, this.album); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return album(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return album?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (album != null) { + return album(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryAlbumImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryAlbum implements PlaybackHistoryItem { + factory PlaybackHistoryAlbum( + {required final DateTime date, + required final AlbumSimple album}) = _$PlaybackHistoryAlbumImpl; + + factory PlaybackHistoryAlbum.fromJson(Map json) = + _$PlaybackHistoryAlbumImpl.fromJson; + + @override + DateTime get date; + AlbumSimple get album; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryAlbumImplCopyWith<_$PlaybackHistoryAlbumImpl> + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$PlaybackHistoryTrackImplCopyWith<$Res> + implements $PlaybackHistoryItemCopyWith<$Res> { + factory _$$PlaybackHistoryTrackImplCopyWith(_$PlaybackHistoryTrackImpl value, + $Res Function(_$PlaybackHistoryTrackImpl) then) = + __$$PlaybackHistoryTrackImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime date, Track track}); +} + +/// @nodoc +class __$$PlaybackHistoryTrackImplCopyWithImpl<$Res> + extends _$PlaybackHistoryItemCopyWithImpl<$Res, _$PlaybackHistoryTrackImpl> + implements _$$PlaybackHistoryTrackImplCopyWith<$Res> { + __$$PlaybackHistoryTrackImplCopyWithImpl(_$PlaybackHistoryTrackImpl _value, + $Res Function(_$PlaybackHistoryTrackImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? date = null, + Object? track = null, + }) { + return _then(_$PlaybackHistoryTrackImpl( + date: null == date + ? _value.date + : date // ignore: cast_nullable_to_non_nullable + as DateTime, + track: null == track + ? _value.track + : track // ignore: cast_nullable_to_non_nullable + as Track, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PlaybackHistoryTrackImpl implements PlaybackHistoryTrack { + _$PlaybackHistoryTrackImpl( + {required this.date, required this.track, final String? $type}) + : $type = $type ?? 'track'; + + factory _$PlaybackHistoryTrackImpl.fromJson(Map json) => + _$$PlaybackHistoryTrackImplFromJson(json); + + @override + final DateTime date; + @override + final Track track; + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + String toString() { + return 'PlaybackHistoryItem.track(date: $date, track: $track)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PlaybackHistoryTrackImpl && + (identical(other.date, date) || other.date == date) && + (identical(other.track, track) || other.track == track)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, date, track); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> + get copyWith => + __$$PlaybackHistoryTrackImplCopyWithImpl<_$PlaybackHistoryTrackImpl>( + this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime date, PlaylistSimple playlist) playlist, + required TResult Function(DateTime date, AlbumSimple album) album, + required TResult Function(DateTime date, Track track) track, + }) { + return track(date, this.track); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult? Function(DateTime date, AlbumSimple album)? album, + TResult? Function(DateTime date, Track track)? track, + }) { + return track?.call(date, this.track); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime date, PlaylistSimple playlist)? playlist, + TResult Function(DateTime date, AlbumSimple album)? album, + TResult Function(DateTime date, Track track)? track, + required TResult orElse(), + }) { + if (track != null) { + return track(date, this.track); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(PlaybackHistoryPlaylist value) playlist, + required TResult Function(PlaybackHistoryAlbum value) album, + required TResult Function(PlaybackHistoryTrack value) track, + }) { + return track(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(PlaybackHistoryPlaylist value)? playlist, + TResult? Function(PlaybackHistoryAlbum value)? album, + TResult? Function(PlaybackHistoryTrack value)? track, + }) { + return track?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(PlaybackHistoryPlaylist value)? playlist, + TResult Function(PlaybackHistoryAlbum value)? album, + TResult Function(PlaybackHistoryTrack value)? track, + required TResult orElse(), + }) { + if (track != null) { + return track(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$PlaybackHistoryTrackImplToJson( + this, + ); + } +} + +abstract class PlaybackHistoryTrack implements PlaybackHistoryItem { + factory PlaybackHistoryTrack( + {required final DateTime date, + required final Track track}) = _$PlaybackHistoryTrackImpl; + + factory PlaybackHistoryTrack.fromJson(Map json) = + _$PlaybackHistoryTrackImpl.fromJson; + + @override + DateTime get date; + Track get track; + @override + @JsonKey(ignore: true) + _$$PlaybackHistoryTrackImplCopyWith<_$PlaybackHistoryTrackImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/utils/migrations/adapters.g.dart b/lib/utils/migrations/adapters.g.dart new file mode 100644 index 00000000..ca95a840 --- /dev/null +++ b/lib/utils/migrations/adapters.g.dart @@ -0,0 +1,600 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'adapters.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class SkipSegmentAdapter extends TypeAdapter { + @override + final int typeId = 2; + + @override + SkipSegment read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SkipSegment( + fields[0] as int, + fields[1] as int, + ); + } + + @override + void write(BinaryWriter writer, SkipSegment obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.start) + ..writeByte(1) + ..write(obj.end); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SkipSegmentAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SourceMatchAdapter extends TypeAdapter { + @override + final int typeId = 6; + + @override + SourceMatch read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return SourceMatch( + id: fields[0] as String, + sourceId: fields[1] as String, + sourceType: fields[2] as SourceType, + createdAt: fields[3] as DateTime, + ); + } + + @override + void write(BinaryWriter writer, SourceMatch obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.sourceId) + ..writeByte(2) + ..write(obj.sourceType) + ..writeByte(3) + ..write(obj.createdAt); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SourceMatchAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +class SourceTypeAdapter extends TypeAdapter { + @override + final int typeId = 5; + + @override + SourceType read(BinaryReader reader) { + switch (reader.readByte()) { + case 0: + return SourceType.youtube; + case 1: + return SourceType.youtubeMusic; + case 2: + return SourceType.jiosaavn; + default: + return SourceType.youtube; + } + } + + @override + void write(BinaryWriter writer, SourceType obj) { + switch (obj) { + case SourceType.youtube: + writer.writeByte(0); + break; + case SourceType.youtubeMusic: + writer.writeByte(1); + break; + case SourceType.jiosaavn: + writer.writeByte(2); + break; + } + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SourceTypeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SourceMatch _$SourceMatchFromJson(Map json) => SourceMatch( + id: json['id'] as String, + sourceId: json['sourceId'] as String, + sourceType: $enumDecode(_$SourceTypeEnumMap, json['sourceType']), + createdAt: DateTime.parse(json['createdAt'] as String), + ); + +Map _$SourceMatchToJson(SourceMatch instance) => + { + 'id': instance.id, + 'sourceId': instance.sourceId, + 'sourceType': _$SourceTypeEnumMap[instance.sourceType]!, + 'createdAt': instance.createdAt.toIso8601String(), + }; + +const _$SourceTypeEnumMap = { + SourceType.youtube: 'youtube', + SourceType.youtubeMusic: 'youtubeMusic', + SourceType.jiosaavn: 'jiosaavn', +}; + +AuthenticationCredentials _$AuthenticationCredentialsFromJson(Map json) => + AuthenticationCredentials( + cookie: json['cookie'] as String, + accessToken: json['accessToken'] as String, + expiration: DateTime.parse(json['expiration'] as String), + ); + +Map _$AuthenticationCredentialsToJson( + AuthenticationCredentials instance) => + { + 'cookie': instance.cookie, + 'accessToken': instance.accessToken, + 'expiration': instance.expiration.toIso8601String(), + }; + +_$UserPreferencesImpl _$$UserPreferencesImplFromJson(Map json) => + _$UserPreferencesImpl( + audioQuality: + $enumDecodeNullable(_$SourceQualitiesEnumMap, json['audioQuality']) ?? + SourceQualities.high, + albumColorSync: json['albumColorSync'] as bool? ?? true, + amoledDarkTheme: json['amoledDarkTheme'] as bool? ?? false, + checkUpdate: json['checkUpdate'] as bool? ?? true, + normalizeAudio: json['normalizeAudio'] as bool? ?? false, + showSystemTrayIcon: json['showSystemTrayIcon'] as bool? ?? false, + skipNonMusic: json['skipNonMusic'] as bool? ?? false, + systemTitleBar: json['systemTitleBar'] as bool? ?? false, + closeBehavior: + $enumDecodeNullable(_$CloseBehaviorEnumMap, json['closeBehavior']) ?? + CloseBehavior.close, + accentColorScheme: UserPreferences._accentColorSchemeReadValue( + json, 'accentColorScheme') == + null + ? const SpotubeColor(0xFF2196F3, name: "Blue") + : UserPreferences._accentColorSchemeFromJson( + UserPreferences._accentColorSchemeReadValue( + json, 'accentColorScheme') as Map), + layoutMode: + $enumDecodeNullable(_$LayoutModeEnumMap, json['layoutMode']) ?? + LayoutMode.adaptive, + locale: UserPreferences._localeReadValue(json, 'locale') == null + ? const Locale("system", "system") + : UserPreferences._localeFromJson( + UserPreferences._localeReadValue(json, 'locale') + as Map), + recommendationMarket: + $enumDecodeNullable(_$MarketEnumMap, json['recommendationMarket']) ?? + Market.US, + searchMode: + $enumDecodeNullable(_$SearchModeEnumMap, json['searchMode']) ?? + SearchMode.youtube, + downloadLocation: json['downloadLocation'] as String? ?? "", + localLibraryLocation: (json['localLibraryLocation'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + pipedInstance: + json['pipedInstance'] as String? ?? "https://pipedapi.kavin.rocks", + themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']) ?? + ThemeMode.system, + audioSource: + $enumDecodeNullable(_$AudioSourceEnumMap, json['audioSource']) ?? + AudioSource.youtube, + streamMusicCodec: $enumDecodeNullable( + _$SourceCodecsEnumMap, json['streamMusicCodec']) ?? + SourceCodecs.weba, + downloadMusicCodec: $enumDecodeNullable( + _$SourceCodecsEnumMap, json['downloadMusicCodec']) ?? + SourceCodecs.m4a, + discordPresence: json['discordPresence'] as bool? ?? true, + endlessPlayback: json['endlessPlayback'] as bool? ?? true, + enableConnect: json['enableConnect'] as bool? ?? false, + ); + +Map _$$UserPreferencesImplToJson( + _$UserPreferencesImpl instance) => + { + 'audioQuality': _$SourceQualitiesEnumMap[instance.audioQuality]!, + 'albumColorSync': instance.albumColorSync, + 'amoledDarkTheme': instance.amoledDarkTheme, + 'checkUpdate': instance.checkUpdate, + 'normalizeAudio': instance.normalizeAudio, + 'showSystemTrayIcon': instance.showSystemTrayIcon, + 'skipNonMusic': instance.skipNonMusic, + 'systemTitleBar': instance.systemTitleBar, + 'closeBehavior': _$CloseBehaviorEnumMap[instance.closeBehavior]!, + 'accentColorScheme': + UserPreferences._accentColorSchemeToJson(instance.accentColorScheme), + 'layoutMode': _$LayoutModeEnumMap[instance.layoutMode]!, + 'locale': UserPreferences._localeToJson(instance.locale), + 'recommendationMarket': _$MarketEnumMap[instance.recommendationMarket]!, + 'searchMode': _$SearchModeEnumMap[instance.searchMode]!, + 'downloadLocation': instance.downloadLocation, + 'localLibraryLocation': instance.localLibraryLocation, + 'pipedInstance': instance.pipedInstance, + 'themeMode': _$ThemeModeEnumMap[instance.themeMode]!, + 'audioSource': _$AudioSourceEnumMap[instance.audioSource]!, + 'streamMusicCodec': _$SourceCodecsEnumMap[instance.streamMusicCodec]!, + 'downloadMusicCodec': _$SourceCodecsEnumMap[instance.downloadMusicCodec]!, + 'discordPresence': instance.discordPresence, + 'endlessPlayback': instance.endlessPlayback, + 'enableConnect': instance.enableConnect, + }; + +const _$SourceQualitiesEnumMap = { + SourceQualities.high: 'high', + SourceQualities.medium: 'medium', + SourceQualities.low: 'low', +}; + +const _$CloseBehaviorEnumMap = { + CloseBehavior.minimizeToTray: 'minimizeToTray', + CloseBehavior.close: 'close', +}; + +const _$LayoutModeEnumMap = { + LayoutMode.compact: 'compact', + LayoutMode.extended: 'extended', + LayoutMode.adaptive: 'adaptive', +}; + +const _$MarketEnumMap = { + Market.AD: 'AD', + Market.AE: 'AE', + Market.AF: 'AF', + Market.AG: 'AG', + Market.AI: 'AI', + Market.AL: 'AL', + Market.AM: 'AM', + Market.AO: 'AO', + Market.AQ: 'AQ', + Market.AR: 'AR', + Market.AS: 'AS', + Market.AT: 'AT', + Market.AU: 'AU', + Market.AW: 'AW', + Market.AX: 'AX', + Market.AZ: 'AZ', + Market.BA: 'BA', + Market.BB: 'BB', + Market.BD: 'BD', + Market.BE: 'BE', + Market.BF: 'BF', + Market.BG: 'BG', + Market.BH: 'BH', + Market.BI: 'BI', + Market.BJ: 'BJ', + Market.BL: 'BL', + Market.BM: 'BM', + Market.BN: 'BN', + Market.BO: 'BO', + Market.BQ: 'BQ', + Market.BR: 'BR', + Market.BS: 'BS', + Market.BT: 'BT', + Market.BV: 'BV', + Market.BW: 'BW', + Market.BY: 'BY', + Market.BZ: 'BZ', + Market.CA: 'CA', + Market.CC: 'CC', + Market.CD: 'CD', + Market.CF: 'CF', + Market.CG: 'CG', + Market.CH: 'CH', + Market.CI: 'CI', + Market.CK: 'CK', + Market.CL: 'CL', + Market.CM: 'CM', + Market.CN: 'CN', + Market.CO: 'CO', + Market.CR: 'CR', + Market.CU: 'CU', + Market.CV: 'CV', + Market.CW: 'CW', + Market.CX: 'CX', + Market.CY: 'CY', + Market.CZ: 'CZ', + Market.DE: 'DE', + Market.DJ: 'DJ', + Market.DK: 'DK', + Market.DM: 'DM', + Market.DO: 'DO', + Market.DZ: 'DZ', + Market.EC: 'EC', + Market.EE: 'EE', + Market.EG: 'EG', + Market.EH: 'EH', + Market.ER: 'ER', + Market.ES: 'ES', + Market.ET: 'ET', + Market.FI: 'FI', + Market.FJ: 'FJ', + Market.FK: 'FK', + Market.FM: 'FM', + Market.FO: 'FO', + Market.FR: 'FR', + Market.GA: 'GA', + Market.GB: 'GB', + Market.GD: 'GD', + Market.GE: 'GE', + Market.GF: 'GF', + Market.GG: 'GG', + Market.GH: 'GH', + Market.GI: 'GI', + Market.GL: 'GL', + Market.GM: 'GM', + Market.GN: 'GN', + Market.GP: 'GP', + Market.GQ: 'GQ', + Market.GR: 'GR', + Market.GS: 'GS', + Market.GT: 'GT', + Market.GU: 'GU', + Market.GW: 'GW', + Market.GY: 'GY', + Market.HK: 'HK', + Market.HM: 'HM', + Market.HN: 'HN', + Market.HR: 'HR', + Market.HT: 'HT', + Market.HU: 'HU', + Market.ID: 'ID', + Market.IE: 'IE', + Market.IL: 'IL', + Market.IM: 'IM', + Market.IN: 'IN', + Market.IO: 'IO', + Market.IQ: 'IQ', + Market.IR: 'IR', + Market.IS: 'IS', + Market.IT: 'IT', + Market.JE: 'JE', + Market.JM: 'JM', + Market.JO: 'JO', + Market.JP: 'JP', + Market.KE: 'KE', + Market.KG: 'KG', + Market.KH: 'KH', + Market.KI: 'KI', + Market.KM: 'KM', + Market.KN: 'KN', + Market.KP: 'KP', + Market.KR: 'KR', + Market.KW: 'KW', + Market.KY: 'KY', + Market.KZ: 'KZ', + Market.LA: 'LA', + Market.LB: 'LB', + Market.LC: 'LC', + Market.LI: 'LI', + Market.LK: 'LK', + Market.LR: 'LR', + Market.LS: 'LS', + Market.LT: 'LT', + Market.LU: 'LU', + Market.LV: 'LV', + Market.LY: 'LY', + Market.MA: 'MA', + Market.MC: 'MC', + Market.MD: 'MD', + Market.ME: 'ME', + Market.MF: 'MF', + Market.MG: 'MG', + Market.MH: 'MH', + Market.MK: 'MK', + Market.ML: 'ML', + Market.MM: 'MM', + Market.MN: 'MN', + Market.MO: 'MO', + Market.MP: 'MP', + Market.MQ: 'MQ', + Market.MR: 'MR', + Market.MS: 'MS', + Market.MT: 'MT', + Market.MU: 'MU', + Market.MV: 'MV', + Market.MW: 'MW', + Market.MX: 'MX', + Market.MY: 'MY', + Market.MZ: 'MZ', + Market.NA: 'NA', + Market.NC: 'NC', + Market.NE: 'NE', + Market.NF: 'NF', + Market.NG: 'NG', + Market.NI: 'NI', + Market.NL: 'NL', + Market.NO: 'NO', + Market.NP: 'NP', + Market.NR: 'NR', + Market.NU: 'NU', + Market.NZ: 'NZ', + Market.OM: 'OM', + Market.PA: 'PA', + Market.PE: 'PE', + Market.PF: 'PF', + Market.PG: 'PG', + Market.PH: 'PH', + Market.PK: 'PK', + Market.PL: 'PL', + Market.PM: 'PM', + Market.PN: 'PN', + Market.PR: 'PR', + Market.PS: 'PS', + Market.PT: 'PT', + Market.PW: 'PW', + Market.PY: 'PY', + Market.QA: 'QA', + Market.RE: 'RE', + Market.RO: 'RO', + Market.RS: 'RS', + Market.RU: 'RU', + Market.RW: 'RW', + Market.SA: 'SA', + Market.SB: 'SB', + Market.SC: 'SC', + Market.SD: 'SD', + Market.SE: 'SE', + Market.SG: 'SG', + Market.SH: 'SH', + Market.SI: 'SI', + Market.SJ: 'SJ', + Market.SK: 'SK', + Market.SL: 'SL', + Market.SM: 'SM', + Market.SN: 'SN', + Market.SO: 'SO', + Market.SR: 'SR', + Market.SS: 'SS', + Market.ST: 'ST', + Market.SV: 'SV', + Market.SX: 'SX', + Market.SY: 'SY', + Market.SZ: 'SZ', + Market.TC: 'TC', + Market.TD: 'TD', + Market.TF: 'TF', + Market.TG: 'TG', + Market.TH: 'TH', + Market.TJ: 'TJ', + Market.TK: 'TK', + Market.TL: 'TL', + Market.TM: 'TM', + Market.TN: 'TN', + Market.TO: 'TO', + Market.TR: 'TR', + Market.TT: 'TT', + Market.TV: 'TV', + Market.TW: 'TW', + Market.TZ: 'TZ', + Market.UA: 'UA', + Market.UG: 'UG', + Market.UM: 'UM', + Market.US: 'US', + Market.UY: 'UY', + Market.UZ: 'UZ', + Market.VA: 'VA', + Market.VC: 'VC', + Market.VE: 'VE', + Market.VG: 'VG', + Market.VI: 'VI', + Market.VN: 'VN', + Market.VU: 'VU', + Market.WF: 'WF', + Market.WS: 'WS', + Market.XK: 'XK', + Market.YE: 'YE', + Market.YT: 'YT', + Market.ZA: 'ZA', + Market.ZM: 'ZM', + Market.ZW: 'ZW', +}; + +const _$SearchModeEnumMap = { + SearchMode.youtube: 'youtube', + SearchMode.youtubeMusic: 'youtubeMusic', +}; + +const _$ThemeModeEnumMap = { + ThemeMode.system: 'system', + ThemeMode.light: 'light', + ThemeMode.dark: 'dark', +}; + +const _$AudioSourceEnumMap = { + AudioSource.youtube: 'youtube', + AudioSource.piped: 'piped', + AudioSource.jiosaavn: 'jiosaavn', +}; + +const _$SourceCodecsEnumMap = { + SourceCodecs.m4a: 'm4a', + SourceCodecs.weba: 'weba', +}; + +_$PlaybackHistoryPlaylistImpl _$$PlaybackHistoryPlaylistImplFromJson( + Map json) => + _$PlaybackHistoryPlaylistImpl( + date: DateTime.parse(json['date'] as String), + playlist: PlaylistSimple.fromJson( + Map.from(json['playlist'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryPlaylistImplToJson( + _$PlaybackHistoryPlaylistImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'playlist': instance.playlist.toJson(), + 'runtimeType': instance.$type, + }; + +_$PlaybackHistoryAlbumImpl _$$PlaybackHistoryAlbumImplFromJson(Map json) => + _$PlaybackHistoryAlbumImpl( + date: DateTime.parse(json['date'] as String), + album: + AlbumSimple.fromJson(Map.from(json['album'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryAlbumImplToJson( + _$PlaybackHistoryAlbumImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'album': instance.album.toJson(), + 'runtimeType': instance.$type, + }; + +_$PlaybackHistoryTrackImpl _$$PlaybackHistoryTrackImplFromJson(Map json) => + _$PlaybackHistoryTrackImpl( + date: DateTime.parse(json['date'] as String), + track: Track.fromJson(Map.from(json['track'] as Map)), + $type: json['runtimeType'] as String?, + ); + +Map _$$PlaybackHistoryTrackImplToJson( + _$PlaybackHistoryTrackImpl instance) => + { + 'date': instance.date.toIso8601String(), + 'track': instance.track.toJson(), + 'runtimeType': instance.$type, + }; diff --git a/lib/utils/migrations/cache_box.dart b/lib/utils/migrations/cache_box.dart new file mode 100644 index 00000000..dfe1947b --- /dev/null +++ b/lib/utils/migrations/cache_box.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +import 'package:hive/hive.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotube/provider/spotify/utils/json_cast.dart'; +import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:spotube/utils/primitive_utils.dart'; + +const kKeyBoxName = "spotube_box_name"; +const kNoEncryptionWarningShownKey = "showedNoEncryptionWarning"; +const kIsUsingEncryption = "isUsingEncryption"; +String getBoxKey(String boxName) => "spotube_box_$boxName"; + +class PersistenceCacheBox { + static late LazyBox _box; + static late LazyBox _encryptedBox; + + final String cacheKey; + final bool encrypted; + + final T Function(Map) fromJson; + + PersistenceCacheBox( + this.cacheKey, { + required this.fromJson, + this.encrypted = false, + }); + + static Future read(String key) async { + final localStorage = await SharedPreferences.getInstance(); + if (kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak)) { + return localStorage.getString(key); + } + + try { + await localStorage.setBool(kIsUsingEncryption, true); + return await EncryptedKvStoreService.storage.read(key: key); + } catch (e) { + await localStorage.setBool(kIsUsingEncryption, false); + return localStorage.getString(key); + } + } + + static Future write(String key, String value) async { + final localStorage = await SharedPreferences.getInstance(); + if (kIsMacOS || kIsIOS || (kIsLinux && !kIsFlatpak)) { + await localStorage.setString(key, value); + return; + } + + try { + await localStorage.setBool(kIsUsingEncryption, true); + await EncryptedKvStoreService.storage.write(key: key, value: value); + } catch (e) { + await localStorage.setBool(kIsUsingEncryption, false); + await localStorage.setString(key, value); + } + } + + static Future initializeBoxes({required String? path}) async { + String? boxName = await read(kKeyBoxName); + + if (boxName == null) { + boxName = "spotube-${PrimitiveUtils.uuid.v4()}"; + await write(kKeyBoxName, boxName); + } + + String? encryptionKey = await read(getBoxKey(boxName)); + + if (encryptionKey == null) { + encryptionKey = base64Url.encode(Hive.generateSecureKey()); + await write(getBoxKey(boxName), encryptionKey); + } + + _encryptedBox = await Hive.openLazyBox( + boxName, + encryptionCipher: HiveAesCipher(base64Url.decode(encryptionKey)), + ); + + _box = await Hive.openLazyBox( + "spotube_cache", + path: path, + ); + } + + LazyBox get box => encrypted ? _encryptedBox : _box; + + Future getData() async { + final json = await box.get(cacheKey); + + if (json != null || + (json is Map && json.entries.isNotEmpty) || + (json is List && json.isNotEmpty)) { + return fromJson(castNestedJson(json)); + } + + return null; + } +} diff --git a/lib/utils/migrations/hive.dart b/lib/utils/migrations/hive.dart new file mode 100644 index 00000000..e43df1d8 --- /dev/null +++ b/lib/utils/migrations/hive.dart @@ -0,0 +1,316 @@ +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; +import 'package:hive/hive.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotube/models/database/database.dart' + hide + SourceType, + AudioSource, + CloseBehavior, + MusicCodec, + LayoutMode, + SearchMode, + BlacklistedType; +import 'package:spotube/models/database/database.dart' as db; +import 'package:spotube/services/kv_store/kv_store.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/utils/migrations/adapters.dart'; +import 'package:spotube/utils/migrations/cache_box.dart'; + +late AppDatabase _database; + +Future getHiveCacheDir() async => + kIsWeb ? null : (await getApplicationSupportDirectory()).path; + +Future migrateAuthenticationInfo() async { + AppLogger.log.i("🔵 Migrating authentication info.."); + + final box = PersistenceCacheBox( + "authentication", + encrypted: true, + fromJson: (json) => AuthenticationCredentials.fromJson(json), + ); + + final credentials = await box.getData(); + + if (credentials == null) return; + + await _database.into(_database.authenticationTable).insertOnConflictUpdate( + AuthenticationTableCompanion.insert( + accessToken: DecryptedText(credentials.accessToken), + cookie: DecryptedText(credentials.cookie), + expiration: credentials.expiration, + id: const Value(0), + ), + ); + + AppLogger.log.i("✅ Migrated authentication info"); +} + +Future migratePreferences() async { + AppLogger.log.i("🔵 Migrating preferences.."); + final box = PersistenceCacheBox( + "preferences", + fromJson: (json) => UserPreferences.fromJson(json), + ); + + final preferences = await box.getData(); + + if (preferences == null) return; + + await _database.into(_database.preferencesTable).insertOnConflictUpdate( + PreferencesTableCompanion.insert( + id: const Value(0), + accentColorScheme: Value(preferences.accentColorScheme), + albumColorSync: Value(preferences.albumColorSync), + amoledDarkTheme: Value(preferences.amoledDarkTheme), + audioQuality: Value(preferences.audioQuality), + audioSource: Value( + switch (preferences.audioSource) { + AudioSource.youtube => db.AudioSource.youtube, + AudioSource.piped => db.AudioSource.piped, + AudioSource.jiosaavn => db.AudioSource.jiosaavn, + }, + ), + checkUpdate: Value(preferences.checkUpdate), + closeBehavior: Value( + switch (preferences.closeBehavior) { + CloseBehavior.minimizeToTray => db.CloseBehavior.minimizeToTray, + CloseBehavior.close => db.CloseBehavior.close, + }, + ), + discordPresence: Value(preferences.discordPresence), + downloadLocation: Value(preferences.downloadLocation), + downloadMusicCodec: Value(preferences.downloadMusicCodec), + enableConnect: Value(preferences.enableConnect), + endlessPlayback: Value(preferences.endlessPlayback), + layoutMode: Value( + switch (preferences.layoutMode) { + LayoutMode.adaptive => db.LayoutMode.adaptive, + LayoutMode.compact => db.LayoutMode.compact, + LayoutMode.extended => db.LayoutMode.extended, + }, + ), + localLibraryLocation: Value(preferences.localLibraryLocation), + locale: Value(preferences.locale), + market: Value(preferences.recommendationMarket), + normalizeAudio: Value(preferences.normalizeAudio), + pipedInstance: Value(preferences.pipedInstance), + searchMode: Value( + switch (preferences.searchMode) { + SearchMode.youtube => db.SearchMode.youtube, + SearchMode.youtubeMusic => db.SearchMode.youtubeMusic, + }, + ), + showSystemTrayIcon: Value(preferences.showSystemTrayIcon), + skipNonMusic: Value(preferences.skipNonMusic), + streamMusicCodec: Value(preferences.streamMusicCodec), + systemTitleBar: Value(preferences.systemTitleBar), + themeMode: Value(preferences.themeMode), + ), + ); + + AppLogger.log.i("✅ Migrated preferences"); +} + +Future migrateSkipSegment() async { + AppLogger.log.i("🔵 Migrating skip segments.."); + Hive.registerAdapter(SkipSegmentAdapter()); + + final box = await Hive.openLazyBox( + SkipSegment.boxName, + path: await getHiveCacheDir(), + ); + + final skipSegments = await Future.wait( + box.keys.map( + (key) async => ( + id: key as String, + data: await box.get(key), + ), + ), + ); + + await _database.batch((batch) { + batch.insertAll( + _database.skipSegmentTable, + skipSegments + .where((element) => element.data != null) + .expand((element) => (element.data as List).map( + (segment) => SkipSegmentTableCompanion.insert( + trackId: element.id, + start: segment["start"], + end: segment["end"], + ), + )) + .toList(), + ); + }); + + AppLogger.log.i("✅ Migrated skip segments"); +} + +Future migrateSourceMatches() async { + AppLogger.log.i("🔵 Migrating source matches.."); + + Hive.registerAdapter(SourceMatchAdapter()); + Hive.registerAdapter(SourceTypeAdapter()); + + final box = await Hive.openBox( + SourceMatch.boxName, + path: await getHiveCacheDir(), + ); + + final sourceMatches = + box.keys.map((key) => (data: box.get(key), trackId: key)); + + await _database.batch((batch) { + batch.insertAll( + _database.sourceMatchTable, + sourceMatches + .where((element) => element.data != null) + .map( + (sourceMatch) => SourceMatchTableCompanion.insert( + sourceId: sourceMatch.data!.sourceId, + trackId: sourceMatch.trackId, + sourceType: Value( + switch (sourceMatch.data!.sourceType) { + SourceType.jiosaavn => db.SourceType.jiosaavn, + SourceType.youtube => db.SourceType.youtube, + SourceType.youtubeMusic => db.SourceType.youtubeMusic, + }, + ), + ), + ) + .toList(), + ); + }); + + AppLogger.log.i("✅ Migrated source matches"); +} + +Future migrateBlacklist() async { + AppLogger.log.i("🔵 Migrating blacklist.."); + + final box = PersistenceCacheBox>( + "blacklist", + fromJson: (json) => (json["blacklist"] as List) + .map((e) => BlacklistedElement.fromJson(e)) + .toSet(), + ); + + final data = await box.getData(); + + if (data == null) return; + + await _database.batch((batch) { + batch.insertAll( + _database.blacklistTable, + data.map( + (element) => BlacklistTableCompanion.insert( + name: element.name, + elementId: element.id, + elementType: switch (element.type) { + BlacklistedType.artist => db.BlacklistedType.artist, + BlacklistedType.track => db.BlacklistedType.track, + }, + ), + ), + ); + }); + + AppLogger.log.i("✅ Migrated blacklist"); +} + +Future migrateLastFmCredentials() async { + AppLogger.log.i("🔵 Migrating Last.fm credentials.."); + + final box = PersistenceCacheBox( + "scrobbler", + fromJson: (json) => ScrobblerState.fromJson(json), + encrypted: true, + ); + + final data = await box.getData(); + + if (data == null) return; + + await _database.into(_database.scrobblerTable).insertOnConflictUpdate( + ScrobblerTableCompanion.insert( + id: const Value(0), + passwordHash: DecryptedText(data.passwordHash), + username: data.username, + ), + ); + + AppLogger.log.i("✅ Migrated Last.fm credentials"); +} + +Future migratePlaybackHistory() async { + AppLogger.log.i("🔵 Migrating playback history.."); + + final box = PersistenceCacheBox( + "playback_history", + fromJson: (json) => PlaybackHistoryState.fromJson(json), + ); + + final data = await box.getData(); + + if (data == null) return; + + await _database.batch((batch) { + batch.insertAll( + _database.historyTable, + data.items.map( + (item) => switch (item) { + PlaybackHistoryAlbum() => HistoryTableCompanion.insert( + createdAt: Value(item.date), + itemId: item.album.id!, + data: item.album.toJson(), + type: db.HistoryEntryType.album, + ), + PlaybackHistoryPlaylist() => HistoryTableCompanion.insert( + createdAt: Value(item.date), + itemId: item.playlist.id!, + data: item.playlist.toJson(), + type: db.HistoryEntryType.playlist, + ), + PlaybackHistoryTrack() => HistoryTableCompanion.insert( + createdAt: Value(item.date), + itemId: item.track.id!, + data: item.track.toJson(), + type: db.HistoryEntryType.track, + ), + _ => throw Exception("Unknown history item type"), + }, + ), + ); + }); + + AppLogger.log.i("✅ Migrated playback history"); +} + +Future migrateFromHiveToDrift(AppDatabase database) async { + if (KVStoreService.hasMigratedToDrift) return; + + await PersistenceCacheBox.initializeBoxes( + path: await getHiveCacheDir(), + ); + + _database = database; + + await migrateAuthenticationInfo(); + await migratePreferences(); + + await migrateSkipSegment(); + await migrateSourceMatches(); + + await migrateBlacklist(); + await migratePlaybackHistory(); + + await migrateLastFmCredentials(); + + await KVStoreService.setHasMigratedToDrift(true); + + AppLogger.log.i("🚀 Migrated all data to Drift"); +} From 261e1b6685f207df1808af30044a63dbafbefb4c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 30 Jun 2024 18:00:50 +0600 Subject: [PATCH 158/261] chore: fix queue collections not being loaded --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +- lib/provider/audio_player/audio_player.dart | 6 +++ lib/provider/history/recent.dart | 45 ++++++++++++--------- 3 files changed, 33 insertions(+), 22 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 64ee89d2..fed66850 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -53,7 +53,7 @@ body: description: Where did you install Spotube from? multiple: true options: - - "Website (spotube.netlify.app) or (spotube.krtirtho.dev)" + - "Website (spotube.krtirtho.dev)" - "GitHub Releases (Binary)" - "GitHub Actions (Nightly Binary)" - "Play Store (Android)" @@ -77,4 +77,4 @@ body: description: If you are a developer and want to work on this issue yourself, you can check this box and wait for maintainer response. We welcome contributions! options: - label: I'm ready to work on this issue! - required: false \ No newline at end of file + required: false diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index 9dfc2c0a..da22b2ce 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -89,6 +89,12 @@ class AudioPlayerNotifier extends Notifier { autoPlay: false, ); } + + if (playerState.collections.isNotEmpty) { + state = state.copyWith( + collections: playerState.collections, + ); + } } Future _updatePlayerState( diff --git a/lib/provider/history/recent.dart b/lib/provider/history/recent.dart index 4e445500..8894b713 100644 --- a/lib/provider/history/recent.dart +++ b/lib/provider/history/recent.dart @@ -9,28 +9,31 @@ class RecentlyPlayedItemNotifier extends AsyncNotifier> { build() async { final database = ref.watch(databaseProvider); - final uniqueItemIds = - await (database.selectOnly(database.historyTable, distinct: true) - ..addColumns([database.historyTable.itemId]) - ..where( - database.historyTable.type.isIn([ - HistoryEntryType.playlist.name, - HistoryEntryType.album.name, - ]), - ) - ..limit(10)) - .map((row) => row.read(database.historyTable.itemId)) - .get() - .then((value) => value.whereNotNull().toList()); + final uniqueItemIds = await (database.selectOnly(database.historyTable, + distinct: true) + ..addColumns([database.historyTable.itemId, database.historyTable.id]) + ..where( + database.historyTable.type.isIn([ + HistoryEntryType.playlist.name, + HistoryEntryType.album.name, + ]), + ) + ..limit(10) + ..orderBy([ + OrderingTerm( + expression: database.historyTable.createdAt, + mode: OrderingMode.desc, + ), + ])) + .map( + (row) => row.read(database.historyTable.id), + ) + .get() + .then((value) => value.whereNotNull().toList()); final query = database.select(database.historyTable) ..where( - (tbl) => - tbl.type.isIn([ - HistoryEntryType.playlist.name, - HistoryEntryType.album.name, - ]) & - tbl.itemId.isIn(uniqueItemIds), + (tbl) => tbl.id.isIn(uniqueItemIds), ) ..orderBy([ (tbl) => OrderingTerm( @@ -45,7 +48,9 @@ class RecentlyPlayedItemNotifier extends AsyncNotifier> { ref.onDispose(() => subscription.cancel()); - return await query.get(); + final items = await query.get(); + + return items; } } From 4c5564fd2f6bb7e6f42896a01e95fe197c362b50 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 30 Jun 2024 18:31:57 +0600 Subject: [PATCH 159/261] chore: use enum properties for history duration in top stats --- lib/provider/history/top.dart | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart index 965fb3ad..82681267 100644 --- a/lib/provider/history/top.dart +++ b/lib/provider/history/top.dart @@ -8,20 +8,24 @@ import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/database/database.dart'; enum HistoryDuration { - allTime, - days7, - days30, - months6, - year, - years2, + allTime(Duration(days: 365 * 2003)), + days7(Duration(days: 7)), + days30(Duration(days: 30)), + months6(Duration(days: 30 * 6)), + year(Duration(days: 365)), + years2(Duration(days: 365 * 2)); + + final Duration duration; + + const HistoryDuration(this.duration); } final playbackHistoryTopDurationProvider = StateProvider((ref) => HistoryDuration.days30); -typedef PlaybackHistoryTrack = ({int count, Track track}); typedef PlaybackHistoryAlbum = ({int count, AlbumSimple album}); typedef PlaybackHistoryPlaylist = ({int count, PlaylistSimple playlist}); +typedef PlaybackHistoryTrack = ({int count, Track track}); typedef PlaybackHistoryArtist = ({int count, Artist artist}); class PlaybackHistoryTopState { @@ -58,14 +62,7 @@ class PlaybackHistoryTopNotifier build(arg) async { final database = ref.watch(databaseProvider); - final duration = switch (arg) { - HistoryDuration.allTime => const Duration(days: 365 * 2003), - HistoryDuration.days7 => const Duration(days: 7), - HistoryDuration.days30 => const Duration(days: 30), - HistoryDuration.months6 => const Duration(days: 30 * 6), - HistoryDuration.year => const Duration(days: 365), - HistoryDuration.years2 => const Duration(days: 365 * 2), - }; + final duration = arg.duration; final tracksQuery = (database.select(database.historyTable) ..where( From 3bdc46da4d05e675be11a41064029eb3c98baa5d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 30 Jun 2024 21:08:29 +0600 Subject: [PATCH 160/261] feat(stats): add lazy loading support --- lib/models/database/database.dart | 3 +- lib/modules/stats/top/albums.dart | 22 ++- lib/modules/stats/top/artists.dart | 42 +++-- lib/modules/stats/top/tracks.dart | 44 +++-- lib/pages/stats/albums/albums.dart | 41 +++-- lib/pages/stats/artists/artists.dart | 43 +++-- lib/pages/stats/fees/fees.dart | 42 +++-- lib/pages/stats/minutes/minutes.dart | 45 +++-- lib/pages/stats/playlists/playlists.dart | 44 +++-- lib/pages/stats/streams/streams.dart | 45 +++-- lib/provider/history/top.dart | 200 ----------------------- lib/provider/history/top/albums.dart | 135 +++++++++++++++ lib/provider/history/top/playlists.dart | 104 ++++++++++++ lib/provider/history/top/tracks.dart | 119 ++++++++++++++ 14 files changed, 610 insertions(+), 319 deletions(-) create mode 100644 lib/provider/history/top/albums.dart create mode 100644 lib/provider/history/top/playlists.dart create mode 100644 lib/provider/history/top/tracks.dart diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 609d6771..1c233f84 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart'; +import 'package:drift/extensions/json1.dart'; import 'package:encrypt/encrypt.dart'; import 'package:media_kit/media_kit.dart' hide Track; import 'package:path/path.dart'; @@ -13,7 +14,7 @@ import 'package:spotube/models/lyrics.dart'; import 'package:spotube/services/kv_store/encrypted_kv_store.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:spotube/services/sourced_track/enums.dart'; -import 'package:flutter/material.dart' hide Table, Key; +import 'package:flutter/material.dart' hide Table, Key, View; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; import 'package:drift/native.dart'; import 'package:sqlite3/sqlite3.dart'; diff --git a/lib/modules/stats/top/albums.dart b/lib/modules/stats/top/albums.dart index bcaa75c5..4329b871 100644 --- a/lib/modules/stats/top/albums.dart +++ b/lib/modules/stats/top/albums.dart @@ -4,6 +4,9 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/history/top/albums.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class TopAlbums extends HookConsumerWidget { const TopAlbums({super.key}); @@ -11,14 +14,21 @@ class TopAlbums extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final albums = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.whenData((s) => s.albums))); + final topAlbums = ref.watch(historyTopAlbumsProvider(historyDuration)); + final topAlbumsNotifier = + ref.watch(historyTopAlbumsProvider(historyDuration).notifier); - final albumsData = albums.asData?.value ?? []; + final albumsData = topAlbums.asData?.value.items ?? []; - return Skeletonizer( - enabled: albums.isLoading, - child: SliverList.builder( + return Skeletonizer.sliver( + enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage, + child: SliverInfiniteList( + onFetchData: () async { + await topAlbumsNotifier.fetchMore(); + }, + hasError: topAlbums.hasError, + isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage, + hasReachedMax: topAlbums.asData?.value.hasMore ?? true, itemCount: albumsData.length, itemBuilder: (context, index) { final album = albumsData[index]; diff --git a/lib/modules/stats/top/artists.dart b/lib/modules/stats/top/artists.dart index 094353f2..d5eb2d0e 100644 --- a/lib/modules/stats/top/artists.dart +++ b/lib/modules/stats/top/artists.dart @@ -1,8 +1,13 @@ 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:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/history/top/tracks.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class TopArtists extends HookConsumerWidget { const TopArtists({super.key}); @@ -10,20 +15,33 @@ class TopArtists extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final artists = ref.watch(playbackHistoryTopProvider(historyDuration) - .select((value) => value.whenData((s) => s.artists))); + final topTracks = ref.watch( + historyTopTracksProvider(historyDuration), + ); + final topTracksNotifier = + ref.watch(historyTopTracksProvider(historyDuration).notifier); - final artistsData = artists.asData?.value ?? []; + final artistsData = useMemoized( + () => topTracks.asData?.value.artists ?? [], [topTracks.asData?.value]); - return SliverList.builder( - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text("${compactNumberFormatter.format(artist.count)} plays"), - ); - }, + return Skeletonizer.sliver( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: SliverInfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: artistsData.length, + itemBuilder: (context, index) { + final artist = artistsData[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text("${compactNumberFormatter.format(artist.count)} plays"), + ); + }, + ), ); } } diff --git a/lib/modules/stats/top/tracks.dart b/lib/modules/stats/top/tracks.dart index 8bffa800..be457b2e 100644 --- a/lib/modules/stats/top/tracks.dart +++ b/lib/modules/stats/top/tracks.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/history/top/tracks.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class TopTracks extends HookConsumerWidget { const TopTracks({super.key}); @@ -10,24 +14,34 @@ class TopTracks extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final historyDuration = ref.watch(playbackHistoryTopDurationProvider); - final tracks = ref.watch( - playbackHistoryTopProvider(historyDuration) - .select((value) => value.whenData((s) => s.tracks)), + final topTracks = ref.watch( + historyTopTracksProvider(historyDuration), ); + final topTracksNotifier = + ref.watch(historyTopTracksProvider(historyDuration).notifier); - final tracksData = tracks.asData?.value ?? []; + final tracksData = topTracks.asData?.value.items ?? []; - return SliverList.builder( - itemCount: tracksData.length, - itemBuilder: (context, index) { - final track = tracksData[index]; - return StatsTrackItem( - track: track.track, - info: Text( - "${compactNumberFormatter.format(track.count)} plays", - ), - ); - }, + return Skeletonizer.sliver( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: SliverInfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: tracksData.length, + itemBuilder: (context, index) { + final track = tracksData[index]; + return StatsTrackItem( + track: track.track, + info: Text( + "${compactNumberFormatter.format(track.count)} plays", + ), + ); + }, + ), ); } } diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index 859eaf26..db0eedf6 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/album_item.dart'; import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/history/top/albums.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class StatsAlbumsPage extends HookConsumerWidget { static const name = "stats_albums"; @@ -12,10 +16,12 @@ class StatsAlbumsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final albums = ref.watch(playbackHistoryTopProvider(HistoryDuration.allTime) - .select((value) => value.whenData((s) => s.albums))); + final topAlbums = + ref.watch(historyTopAlbumsProvider(HistoryDuration.allTime)); + final topAlbumsNotifier = + ref.watch(historyTopAlbumsProvider(HistoryDuration.allTime).notifier); - final albumsData = albums.asData?.value ?? []; + final albumsData = topAlbums.asData?.value.items ?? []; return Scaffold( appBar: const PageWindowTitleBar( @@ -23,15 +29,26 @@ class StatsAlbumsPage extends HookConsumerWidget { centerTitle: false, title: Text("Albums"), ), - body: ListView.builder( - itemCount: albumsData.length, - itemBuilder: (context, index) { - final album = albumsData[index]; - return StatsAlbumItem( - album: album.album, - info: Text("${compactNumberFormatter.format(album.count)} plays"), - ); - }, + body: Skeletonizer( + enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage, + child: InfiniteList( + onFetchData: () async { + await topAlbumsNotifier.fetchMore(); + }, + hasError: topAlbums.hasError, + isLoading: topAlbums.isLoading && !topAlbums.isLoadingNextPage, + hasReachedMax: topAlbums.asData?.value.hasMore ?? true, + itemCount: albumsData.length, + itemBuilder: (context, index) { + final album = albumsData[index]; + return StatsAlbumItem( + album: album.album, + info: Text( + "${compactNumberFormatter.format(album.count)} plays", + ), + ); + }, + ), ), ); } diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart index e6dadd95..80ff5f23 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -1,10 +1,15 @@ 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:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/history/top/tracks.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class StatsArtistsPage extends HookConsumerWidget { static const name = "stats_artists"; @@ -12,12 +17,14 @@ class StatsArtistsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final artists = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.whenData((s) => s.artists)), + final topTracks = ref.watch( + historyTopTracksProvider(HistoryDuration.allTime), ); + final topTracksNotifier = + ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier); - final artistsData = artists.asData?.value ?? []; + final artistsData = useMemoized( + () => topTracks.asData?.value.artists ?? [], [topTracks.asData?.value]); return Scaffold( appBar: const PageWindowTitleBar( @@ -25,15 +32,25 @@ class StatsArtistsPage extends HookConsumerWidget { centerTitle: false, title: Text("Artists"), ), - body: ListView.builder( - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text("${compactNumberFormatter.format(artist.count)} plays"), - ); - }, + body: Skeletonizer( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: InfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: artistsData.length, + itemBuilder: (context, index) { + final artist = artistsData[index]; + return StatsArtistItem( + artist: artist.artist, + info: + Text("${compactNumberFormatter.format(artist.count)} plays"), + ); + }, + ), ), ); } diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index e1d701eb..0e25c00b 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -1,11 +1,16 @@ 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:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/history/top/tracks.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class StatsStreamFeesPage extends HookConsumerWidget { static const name = "stats_stream_fees"; @@ -16,12 +21,14 @@ class StatsStreamFeesPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final ThemeData(:textTheme, :hintColor) = Theme.of(context); - final artists = ref.watch( - playbackHistoryTopProvider(HistoryDuration.days30) - .select((value) => value.whenData((s) => s.artists)), + final topTracks = ref.watch( + historyTopTracksProvider(HistoryDuration.allTime), ); + final topTracksNotifier = + ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier); - final artistsData = artists.asData?.value ?? []; + final artistsData = useMemoized( + () => topTracks.asData?.value.artists ?? [], [topTracks.asData?.value]); return Scaffold( appBar: const PageWindowTitleBar( @@ -50,15 +57,24 @@ class StatsStreamFeesPage extends HookConsumerWidget { ), ), ), - SliverList.builder( - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text(usdFormatter.format(artist.count * 0.005)), - ); - }, + Skeletonizer.sliver( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: SliverInfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: artistsData.length, + itemBuilder: (context, index) { + final artist = artistsData[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text(usdFormatter.format(artist.count * 0.005)), + ); + }, + ), ), ], ), diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index 587e9007..ea3048ef 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/history/top/tracks.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class StatsMinutesPage extends HookConsumerWidget { static const name = "stats_minutes"; @@ -15,11 +19,12 @@ class StatsMinutesPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final topTracks = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.whenData((s) => s.tracks)), + historyTopTracksProvider(HistoryDuration.allTime), ); + final topTracksNotifier = + ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier); - final topTracksData = topTracks.asData?.value ?? []; + final tracksData = topTracks.asData?.value.items ?? []; return Scaffold( appBar: const PageWindowTitleBar( @@ -27,19 +32,27 @@ class StatsMinutesPage extends HookConsumerWidget { centerTitle: false, automaticallyImplyLeading: true, ), - body: ListView.separated( - separatorBuilder: (context, index) => const Gap(8), - itemCount: topTracksData.length, - itemBuilder: (context, index) { - final (:track, :count) = topTracksData[index]; - - return StatsTrackItem( - track: track, - info: Text( - "${compactNumberFormatter.format(count * track.duration!.inMinutes)} mins", - ), - ); - }, + body: Skeletonizer( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: InfiniteList( + separatorBuilder: (context, index) => const Gap(8), + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: tracksData.length, + itemBuilder: (context, index) { + final track = tracksData[index]; + return StatsTrackItem( + track: track.track, + info: Text( + "${compactNumberFormatter.format(track.count)} plays", + ), + ); + }, + ), ), ); } diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index f5ee62d0..a6db3e1c 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -1,10 +1,14 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/playlist_item.dart'; import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/history/top/playlists.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class StatsPlaylistsPage extends HookConsumerWidget { static const name = "stats_playlists"; @@ -12,12 +16,13 @@ class StatsPlaylistsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final playlists = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.whenData((s) => s.playlists)), - ); + final topPlaylists = + ref.watch(historyTopPlaylistsProvider(HistoryDuration.allTime)); - final playlistsData = playlists.asData?.value ?? []; + final topPlaylistsNotifier = ref + .watch(historyTopPlaylistsProvider(HistoryDuration.allTime).notifier); + + final playlistsData = topPlaylists.asData?.value.items ?? []; return Scaffold( appBar: const PageWindowTitleBar( @@ -25,16 +30,25 @@ class StatsPlaylistsPage extends HookConsumerWidget { centerTitle: false, title: Text("Playlists"), ), - body: ListView.builder( - itemCount: playlistsData.length, - itemBuilder: (context, index) { - final playlist = playlistsData[index]; - return StatsPlaylistItem( - playlist: playlist.playlist, - info: - Text("${compactNumberFormatter.format(playlist.count)} plays"), - ); - }, + body: Skeletonizer( + enabled: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage, + child: InfiniteList( + onFetchData: () async { + await topPlaylistsNotifier.fetchMore(); + }, + hasError: topPlaylists.hasError, + isLoading: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage, + hasReachedMax: topPlaylists.asData?.value.hasMore ?? true, + itemCount: playlistsData.length, + itemBuilder: (context, index) { + final playlist = playlistsData[index]; + return StatsPlaylistItem( + playlist: playlist.playlist, + info: Text( + "${compactNumberFormatter.format(playlist.count)} plays"), + ); + }, + ), ), ); } diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index 20e8ff96..dd5856d0 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/history/top/tracks.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:very_good_infinite_list/very_good_infinite_list.dart'; class StatsStreamsPage extends HookConsumerWidget { static const name = "stats_streams"; @@ -15,11 +19,12 @@ class StatsStreamsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final topTracks = ref.watch( - playbackHistoryTopProvider(HistoryDuration.allTime) - .select((s) => s.whenData((s) => s.tracks)), + historyTopTracksProvider(HistoryDuration.allTime), ); + final topTracksNotifier = + ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier); - final topTracksData = topTracks.asData?.value ?? []; + final tracksData = topTracks.asData?.value.items ?? []; return Scaffold( appBar: const PageWindowTitleBar( @@ -27,19 +32,27 @@ class StatsStreamsPage extends HookConsumerWidget { centerTitle: false, automaticallyImplyLeading: true, ), - body: ListView.separated( - separatorBuilder: (context, index) => const Gap(8), - itemCount: topTracksData.length, - itemBuilder: (context, index) { - final (:track, :count) = topTracksData[index]; - - return StatsTrackItem( - track: track, - info: Text( - "${compactNumberFormatter.format(count)} streams", - ), - ); - }, + body: Skeletonizer( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: InfiniteList( + separatorBuilder: (context, index) => const Gap(8), + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: tracksData.length, + itemBuilder: (context, index) { + final track = tracksData[index]; + return StatsTrackItem( + track: track.track, + info: Text( + "${compactNumberFormatter.format(track.count * track.track.duration!.inMinutes)} mins", + ), + ); + }, + ), ), ); } diff --git a/lib/provider/history/top.dart b/lib/provider/history/top.dart index 82681267..b52e65e2 100644 --- a/lib/provider/history/top.dart +++ b/lib/provider/history/top.dart @@ -1,11 +1,4 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotify/spotify.dart'; -import 'package:spotube/models/database/database.dart'; -import 'package:spotube/provider/database/database.dart'; enum HistoryDuration { allTime(Duration(days: 365 * 2003)), @@ -22,196 +15,3 @@ enum HistoryDuration { final playbackHistoryTopDurationProvider = StateProvider((ref) => HistoryDuration.days30); - -typedef PlaybackHistoryAlbum = ({int count, AlbumSimple album}); -typedef PlaybackHistoryPlaylist = ({int count, PlaylistSimple playlist}); -typedef PlaybackHistoryTrack = ({int count, Track track}); -typedef PlaybackHistoryArtist = ({int count, Artist artist}); - -class PlaybackHistoryTopState { - final List tracks; - final List albums; - final List playlists; - final List artists; - - const PlaybackHistoryTopState({ - required this.tracks, - required this.albums, - required this.playlists, - required this.artists, - }); - - PlaybackHistoryTopState copyWith({ - List? tracks, - List? albums, - List? playlists, - List? artists, - }) { - return PlaybackHistoryTopState( - tracks: tracks ?? this.tracks, - albums: albums ?? this.albums, - playlists: playlists ?? this.playlists, - artists: artists ?? this.artists, - ); - } -} - -class PlaybackHistoryTopNotifier - extends FamilyAsyncNotifier { - @override - build(arg) async { - final database = ref.watch(databaseProvider); - - final duration = arg.duration; - - final tracksQuery = (database.select(database.historyTable) - ..where( - (tbl) => - tbl.type.equalsValue(HistoryEntryType.track) & - tbl.createdAt.isBiggerOrEqualValue( - DateTime.now().subtract(duration), - ), - )); - - final albumsQuery = database.select(database.historyTable) - ..where( - (tbl) => - tbl.type.equalsValue(HistoryEntryType.album) & - tbl.createdAt.isBiggerOrEqualValue( - DateTime.now().subtract(duration), - ), - ); - - final playlistsQuery = database.select(database.historyTable) - ..where( - (tbl) => - tbl.type.equalsValue(HistoryEntryType.playlist) & - tbl.createdAt.isBiggerOrEqualValue( - DateTime.now().subtract(duration), - ), - ); - - final subscriptions = [ - tracksQuery.watch().listen((event) { - if (state.asData == null) return; - final artists = event - .map((track) => track.track!.artists) - .expand((e) => e ?? []); - state = AsyncData(state.asData!.value.copyWith( - tracks: getTracksWithCount(event), - artists: getArtistsWithCount(artists), - )); - }), - albumsQuery.watch().listen((event) async { - if (state.asData == null) return; - final tracks = await tracksQuery.get(); - - final albumsWithTrackAlbums = [ - for (final historicAlbum in event) historicAlbum.album!, - for (final track in tracks) track.track!.album! - ]; - - state = AsyncData(state.asData!.value.copyWith( - albums: getAlbumsWithCount(albumsWithTrackAlbums), - )); - }), - playlistsQuery.watch().listen((event) { - if (state.asData == null) return; - state = AsyncData(state.asData!.value.copyWith( - playlists: getPlaylistsWithCount(event), - )); - }), - ]; - - ref.onDispose(() { - for (final subscription in subscriptions) { - subscription.cancel(); - } - }); - - return database.transaction(() async { - final tracks = await tracksQuery.get(); - final albums = await albumsQuery.get(); - final playlists = await playlistsQuery.get(); - - final tracksWithCount = getTracksWithCount(tracks); - - final albumsWithTrackAlbums = [ - for (final historicAlbum in albums) historicAlbum.album!, - for (final track in tracks) track.track!.album! - ]; - - final albumsWithCount = getAlbumsWithCount(albumsWithTrackAlbums); - - final artists = tracks - .map((track) => track.track!.artists) - .expand((e) => e ?? []); - - final artistsWithCount = getArtistsWithCount(artists); - - final playlistsWithCount = getPlaylistsWithCount(playlists); - - return PlaybackHistoryTopState( - tracks: tracksWithCount, - albums: albumsWithCount, - artists: artistsWithCount, - playlists: playlistsWithCount, - ); - }); - } - - List getTracksWithCount(List tracks) { - return groupBy( - tracks, - (track) => track.track!.id!, - ) - .entries - .map((entry) { - return (count: entry.value.length, track: entry.value.first.track!); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - } - - List getAlbumsWithCount( - List albumsWithTrackAlbums, - ) { - return groupBy(albumsWithTrackAlbums, (album) => album.id!) - .entries - .map((entry) { - return (count: entry.value.length, album: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - } - - List getArtistsWithCount(Iterable artists) { - return groupBy(artists, (artist) => artist.id!) - .entries - .map((entry) { - return (count: entry.value.length, artist: entry.value.first); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - } - - List getPlaylistsWithCount( - List playlists, - ) { - return groupBy(playlists, (playlist) => playlist.playlist!.id!) - .entries - .map((entry) { - return ( - count: entry.value.length, - playlist: entry.value.first.playlist!, - ); - }) - .sorted((a, b) => b.count.compareTo(a.count)) - .toList(); - } -} - -final playbackHistoryTopProvider = AsyncNotifierProviderFamily< - PlaybackHistoryTopNotifier, - PlaybackHistoryTopState, - HistoryDuration>(PlaybackHistoryTopNotifier.new); diff --git a/lib/provider/history/top/albums.dart b/lib/provider/history/top/albums.dart new file mode 100644 index 00000000..84518418 --- /dev/null +++ b/lib/provider/history/top/albums.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +typedef PlaybackHistoryAlbum = ({int count, AlbumSimple album}); + +class HistoryTopAlbumsState extends PaginatedState { + HistoryTopAlbumsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + HistoryTopAlbumsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return HistoryTopAlbumsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class HistoryTopAlbumsNotifier extends FamilyPaginatedAsyncNotifier< + PlaybackHistoryAlbum, HistoryTopAlbumsState, HistoryDuration> { + HistoryTopAlbumsNotifier() : super(); + + Selectable createAlbumsQuery({int? limit, int? offset}) { + final database = ref.read(databaseProvider); + + final duration = switch (arg) { + HistoryDuration.allTime => '0', + HistoryDuration.days7 => "strftime('%s', 'now', 'weekday 0', '-7 days')", + HistoryDuration.days30 => "strftime('%s', 'now', 'start of month')", + HistoryDuration.months6 => + "strftime('%s', 'start of month', '-5 months')", + HistoryDuration.year => "strftime('%s', 'start of year')", + HistoryDuration.years2 => "strftime('%s', 'start of year', '-1 year')", + }; + + return database.customSelect( + """ + SELECT + history_table.created_at, + """ + r""" + json_extract(history_table.data, '$.album') as data, + json_extract(history_table.data, '$.album.id') as item_id, + json_extract(history_table.data, '$.album.type') as type + """ + """ + FROM history_table + WHERE type = 'track' AND + created_at >= $duration + UNION ALL + SELECT + history_table.created_at, + history_table.data, + history_table.item_id, + history_table.type + FROM history_table + WHERE type = 'album' AND + created_at >= $duration + ORDER BY created_at desc + ${limit != null && offset != null ? 'LIMIT $limit OFFSET $offset' : ''} + """, + readsFrom: {database.historyTable}, + ).map((row) { + final data = row.read('data'); + final album = AlbumSimple.fromJson(jsonDecode(data)); + return album; + }); + } + + @override + fetch(arg, offset, limit) async { + final albumsQuery = createAlbumsQuery(limit: limit, offset: offset); + + return getAlbumsWithCount(await albumsQuery.get()); + } + + @override + build(arg) async { + final albums = await fetch(arg, 0, 20); + + final subscription = createAlbumsQuery().watch().listen((event) { + if (state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + items: getAlbumsWithCount(event), + hasMore: false, + )); + }); + + ref.onDispose(() { + subscription.cancel(); + }); + + return HistoryTopAlbumsState( + items: albums, + offset: albums.length, + limit: 20, + hasMore: true, + ); + } + + List getAlbumsWithCount( + List albumsWithTrackAlbums, + ) { + return groupBy(albumsWithTrackAlbums, (album) => album.id!) + .entries + .map((entry) { + return (count: entry.value.length, album: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } +} + +final historyTopAlbumsProvider = AsyncNotifierProviderFamily< + HistoryTopAlbumsNotifier, HistoryTopAlbumsState, HistoryDuration>( + () => HistoryTopAlbumsNotifier(), +); diff --git a/lib/provider/history/top/playlists.dart b/lib/provider/history/top/playlists.dart new file mode 100644 index 00000000..04071f7a --- /dev/null +++ b/lib/provider/history/top/playlists.dart @@ -0,0 +1,104 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +typedef PlaybackHistoryPlaylist = ({int count, PlaylistSimple playlist}); + +class HistoryTopPlaylistsState extends PaginatedState { + HistoryTopPlaylistsState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + @override + HistoryTopPlaylistsState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return HistoryTopPlaylistsState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class HistoryTopPlaylistsNotifier extends FamilyPaginatedAsyncNotifier< + PlaybackHistoryPlaylist, HistoryTopPlaylistsState, HistoryDuration> { + HistoryTopPlaylistsNotifier() : super(); + + SimpleSelectStatement<$HistoryTableTable, HistoryTableData> + createPlaylistsQuery() { + final database = ref.read(databaseProvider); + + return database.select(database.historyTable) + ..where( + (tbl) => + tbl.type.equalsValue(HistoryEntryType.playlist) & + tbl.createdAt.isBiggerOrEqualValue( + DateTime.now().subtract(arg.duration), + ), + ); + } + + @override + fetch(arg, offset, limit) async { + final playlistsQuery = createPlaylistsQuery()..limit(limit, offset: offset); + + return getPlaylistsWithCount(await playlistsQuery.get()); + } + + @override + build(arg) async { + final playlists = await fetch(arg, 0, 20); + + final subscription = createPlaylistsQuery().watch().listen((event) { + if (state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + items: getPlaylistsWithCount(event), + hasMore: false, + )); + }); + + ref.onDispose(() { + subscription.cancel(); + }); + + return HistoryTopPlaylistsState( + items: playlists, + offset: playlists.length, + limit: 20, + hasMore: true, + ); + } + + List getPlaylistsWithCount( + List playlists, + ) { + return groupBy(playlists, (playlist) => playlist.playlist!.id!) + .entries + .map((entry) { + return ( + count: entry.value.length, + playlist: entry.value.first.playlist!, + ); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } +} + +final historyTopPlaylistsProvider = AsyncNotifierProviderFamily< + HistoryTopPlaylistsNotifier, HistoryTopPlaylistsState, HistoryDuration>( + () => HistoryTopPlaylistsNotifier(), +); diff --git a/lib/provider/history/top/tracks.dart b/lib/provider/history/top/tracks.dart new file mode 100644 index 00000000..6c4e44b7 --- /dev/null +++ b/lib/provider/history/top/tracks.dart @@ -0,0 +1,119 @@ +import 'package:collection/collection.dart'; +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/database/database.dart'; +import 'package:spotube/provider/history/top.dart'; +import 'package:spotube/provider/spotify/spotify.dart'; + +typedef PlaybackHistoryTrack = ({int count, Track track}); +typedef PlaybackHistoryArtist = ({int count, Artist artist}); + +class HistoryTopTracksState extends PaginatedState { + HistoryTopTracksState({ + required super.items, + required super.offset, + required super.limit, + required super.hasMore, + }); + + List get artists { + return getArtistsWithCount( + items.expand((e) => e.track.artists ?? []), + ); + } + + List getArtistsWithCount(Iterable artists) { + return groupBy(artists, (artist) => artist.id!) + .entries + .map((entry) { + return (count: entry.value.length, artist: entry.value.first); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } + + @override + HistoryTopTracksState copyWith({ + List? items, + int? offset, + int? limit, + bool? hasMore, + }) { + return HistoryTopTracksState( + items: items ?? this.items, + offset: offset ?? this.offset, + limit: limit ?? this.limit, + hasMore: hasMore ?? this.hasMore, + ); + } +} + +class HistoryTopTracksNotifier extends FamilyPaginatedAsyncNotifier< + PlaybackHistoryTrack, HistoryTopTracksState, HistoryDuration> { + HistoryTopTracksNotifier() : super(); + + SimpleSelectStatement<$HistoryTableTable, HistoryTableData> + createTracksQuery() { + final database = ref.read(databaseProvider); + + return database.select(database.historyTable) + ..where( + (tbl) => + tbl.type.equalsValue(HistoryEntryType.track) & + tbl.createdAt.isBiggerOrEqualValue( + DateTime.now().subtract(arg.duration), + ), + ); + } + + @override + fetch(arg, offset, limit) async { + final tracksQuery = createTracksQuery()..limit(limit, offset: offset); + + return getTracksWithCount(await tracksQuery.get()); + } + + @override + build(arg) async { + final tracks = await fetch(arg, 0, 20); + + final subscription = createTracksQuery().watch().listen((event) { + if (state.asData == null) return; + state = AsyncData(state.asData!.value.copyWith( + items: getTracksWithCount(event), + hasMore: false, + )); + }); + + ref.onDispose(() { + subscription.cancel(); + }); + + return HistoryTopTracksState( + items: tracks, + offset: tracks.length, + limit: 20, + hasMore: true, + ); + } + + List getTracksWithCount(List tracks) { + return groupBy( + tracks, + (track) => track.track!.id!, + ) + .entries + .map((entry) { + return (count: entry.value.length, track: entry.value.first.track!); + }) + .sorted((a, b) => b.count.compareTo(a.count)) + .toList(); + } +} + +final historyTopTracksProvider = AsyncNotifierProviderFamily< + HistoryTopTracksNotifier, HistoryTopTracksState, HistoryDuration>( + () => HistoryTopTracksNotifier(), +); From 7927a3e404cce1b828d4bc079911f85884d44a34 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 1 Jul 2024 13:25:28 +0600 Subject: [PATCH 161/261] chore: fix top album and track invalid time frame operations --- lib/models/database/database.dart | 1 - lib/modules/stats/common/album_item.dart | 2 +- lib/pages/stats/fees/fees.dart | 96 +++++++++++++++++++----- lib/provider/history/top/albums.dart | 9 ++- lib/provider/history/top/tracks.dart | 23 +++++- 5 files changed, 103 insertions(+), 28 deletions(-) diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 1c233f84..74f588ab 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -4,7 +4,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:drift/drift.dart'; -import 'package:drift/extensions/json1.dart'; import 'package:encrypt/encrypt.dart'; import 'package:media_kit/media_kit.dart' hide Track; import 'package:path/path.dart'; diff --git a/lib/modules/stats/common/album_item.dart b/lib/modules/stats/common/album_item.dart index 0424ca70..58604c45 100644 --- a/lib/modules/stats/common/album_item.dart +++ b/lib/modules/stats/common/album_item.dart @@ -33,7 +33,7 @@ class StatsAlbumItem extends StatelessWidget { Text("${album.albumType?.formatted} • "), Flexible( child: ArtistLink( - artists: album.artists!, + artists: album.artists ?? [], mainAxisAlignment: WrapAlignment.start, ), ), diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index 0e25c00b..33d223ae 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -20,16 +20,25 @@ class StatsStreamFeesPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme, :hintColor) = Theme.of(context); + final duration = useState(HistoryDuration.days30); final topTracks = ref.watch( - historyTopTracksProvider(HistoryDuration.allTime), + historyTopTracksProvider(duration.value), ); final topTracksNotifier = - ref.watch(historyTopTracksProvider(HistoryDuration.allTime).notifier); + ref.watch(historyTopTracksProvider(duration.value).notifier); final artistsData = useMemoized( () => topTracks.asData?.value.artists ?? [], [topTracks.asData?.value]); + final total = useMemoized( + () => artistsData.fold( + 0, + (previousValue, element) => previousValue + element.count * 0.005, + ), + [artistsData], + ); + return Scaffold( appBar: const PageWindowTitleBar( automaticallyImplyLeading: true, @@ -57,23 +66,72 @@ class StatsStreamFeesPage extends HookConsumerWidget { ), ), ), - Skeletonizer.sliver( - enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, - child: SliverInfiniteList( - onFetchData: () async { - await topTracksNotifier.fetchMore(); - }, - hasError: topTracks.hasError, - isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, - hasReachedMax: topTracks.asData?.value.hasMore ?? true, - itemCount: artistsData.length, - itemBuilder: (context, index) { - final artist = artistsData[index]; - return StatsArtistItem( - artist: artist.artist, - info: Text(usdFormatter.format(artist.count * 0.005)), - ); - }, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Total ${usdFormatter.format(total)}", + style: textTheme.titleLarge, + ), + DropdownButton( + value: duration.value, + onChanged: (value) { + if (value == null) return; + duration.value = value; + }, + items: const [ + DropdownMenuItem( + value: HistoryDuration.days7, + child: Text("This week"), + ), + DropdownMenuItem( + value: HistoryDuration.days30, + child: Text("This month"), + ), + DropdownMenuItem( + value: HistoryDuration.months6, + child: Text("Last 6 months"), + ), + DropdownMenuItem( + value: HistoryDuration.year, + child: Text("This year"), + ), + DropdownMenuItem( + value: HistoryDuration.years2, + child: Text("Last 2 years"), + ), + DropdownMenuItem( + value: HistoryDuration.allTime, + child: Text("All time"), + ), + ], + ), + ], + ), + ), + ), + SliverSafeArea( + sliver: Skeletonizer.sliver( + enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, + child: SliverInfiniteList( + onFetchData: () async { + await topTracksNotifier.fetchMore(); + }, + hasError: topTracks.hasError, + isLoading: topTracks.isLoading && !topTracks.isLoadingNextPage, + hasReachedMax: topTracks.asData?.value.hasMore ?? true, + itemCount: artistsData.length, + itemBuilder: (context, index) { + final artist = artistsData[index]; + return StatsArtistItem( + artist: artist.artist, + info: Text(usdFormatter.format(artist.count * 0.005)), + ); + }, + ), ), ), ], diff --git a/lib/provider/history/top/albums.dart b/lib/provider/history/top/albums.dart index 84518418..7448a849 100644 --- a/lib/provider/history/top/albums.dart +++ b/lib/provider/history/top/albums.dart @@ -46,9 +46,10 @@ class HistoryTopAlbumsNotifier extends FamilyPaginatedAsyncNotifier< HistoryDuration.days7 => "strftime('%s', 'now', 'weekday 0', '-7 days')", HistoryDuration.days30 => "strftime('%s', 'now', 'start of month')", HistoryDuration.months6 => - "strftime('%s', 'start of month', '-5 months')", - HistoryDuration.year => "strftime('%s', 'start of year')", - HistoryDuration.years2 => "strftime('%s', 'start of year', '-1 year')", + "strftime('%s', date('now', '-5 months', 'start of month'))", + HistoryDuration.year => "strftime('%s', date('now', 'start of year'))", + HistoryDuration.years2 => + "strftime('%s', date('now', '-1 years', 'start of year'))", }; return database.customSelect( @@ -59,7 +60,7 @@ class HistoryTopAlbumsNotifier extends FamilyPaginatedAsyncNotifier< r""" json_extract(history_table.data, '$.album') as data, json_extract(history_table.data, '$.album.id') as item_id, - json_extract(history_table.data, '$.album.type') as type + 'album' as type """ """ FROM history_table diff --git a/lib/provider/history/top/tracks.dart b/lib/provider/history/top/tracks.dart index 6c4e44b7..56795cc6 100644 --- a/lib/provider/history/top/tracks.dart +++ b/lib/provider/history/top/tracks.dart @@ -62,9 +62,26 @@ class HistoryTopTracksNotifier extends FamilyPaginatedAsyncNotifier< ..where( (tbl) => tbl.type.equalsValue(HistoryEntryType.track) & - tbl.createdAt.isBiggerOrEqualValue( - DateTime.now().subtract(arg.duration), - ), + tbl.createdAt.isBiggerOrEqualValue(switch (arg) { + HistoryDuration.allTime => DateTime(1970), + // from start of the week + HistoryDuration.days7 => DateTime.now() + .subtract(Duration(days: DateTime.now().weekday - 1)), + // from start of the month + HistoryDuration.days30 => + DateTime.now().subtract(Duration(days: DateTime.now().day - 1)), + // from start of the 6th month + HistoryDuration.months6 => DateTime.now() + .subtract(Duration(days: DateTime.now().day - 1)) + .subtract(const Duration(days: 30 * 6)), + // from start of the year + HistoryDuration.year => DateTime.now() + .subtract(Duration(days: DateTime.now().day - 1)) + .subtract(const Duration(days: 30 * 12)), + HistoryDuration.years2 => DateTime.now() + .subtract(Duration(days: DateTime.now().day - 1)) + .subtract(const Duration(days: 30 * 12 * 2)), + }), ); } From cb6b6f142e44771109b708dd7fb7d30f89bbb38e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 1 Jul 2024 19:21:12 +0600 Subject: [PATCH 162/261] chore: playback not working in windows due to using loop back ipv4 address --- lib/provider/server/routes/playback.dart | 1 + lib/services/audio_player/audio_player.dart | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/provider/server/routes/playback.dart b/lib/provider/server/routes/playback.dart index aa380d01..30322a6f 100644 --- a/lib/provider/server/routes/playback.dart +++ b/lib/provider/server/routes/playback.dart @@ -23,6 +23,7 @@ class ServerPlaybackRoutes { try { final track = playlist.tracks.firstWhere((element) => element.id == trackId); + final activeSourcedTrack = ref.read(activeSourcedTrackProvider); final sourcedTrack = activeSourcedTrack?.id == track.id ? activeSourcedTrack diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index bb1a6203..7915dc3b 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -12,6 +12,7 @@ import 'package:media_kit/media_kit.dart' as mk; import 'package:spotube/services/audio_player/playback_state.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/utils/platform.dart'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; @@ -28,7 +29,7 @@ class SpotubeMedia extends mk.Media { }) : super( track is LocalTrack ? track.path - : "http://${InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}", + : "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}", extras: { ...?extras, "track": switch (track) { @@ -42,7 +43,7 @@ class SpotubeMedia extends mk.Media { @override String get uri => track is LocalTrack ? (track as LocalTrack).path - : "http://${InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}"; + : "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}"; factory SpotubeMedia.fromMedia(mk.Media media) { final track = media.uri.startsWith("http") From 15bd58a95597925637bf66866af59c2780aeff97 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Jul 2024 11:21:32 +0600 Subject: [PATCH 163/261] feat(desktop): implement webview based login --- lib/collections/routes.dart | 13 +- lib/main.dart | 2 +- lib/modules/desktop_login/login_form.dart | 69 ---------- lib/pages/desktop_login/desktop_login.dart | 78 ----------- lib/pages/desktop_login/login_tutorial.dart | 125 ------------------ lib/pages/settings/sections/accounts.dart | 44 ++++-- .../authentication/authentication.dart | 4 + linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 6 + pubspec.lock | 9 ++ pubspec.yaml | 5 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 15 files changed, 73 insertions(+), 293 deletions(-) delete mode 100644 lib/modules/desktop_login/login_form.dart delete mode 100644 lib/pages/desktop_login/desktop_login.dart delete mode 100644 lib/pages/desktop_login/login_tutorial.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index b3cba581..3bf1d883 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -34,12 +34,9 @@ import 'package:spotube/pages/stats/streams/streams.dart'; import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; -import 'package:spotube/utils/platform.dart'; import 'package:spotube/components/spotube_page_route.dart'; import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/pages/library/library.dart'; -import 'package:spotube/pages/desktop_login/login_tutorial.dart'; -import 'package:spotube/pages/desktop_login/desktop_login.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/root/root_app.dart'; import 'package:spotube/pages/settings/settings.dart'; @@ -313,16 +310,8 @@ final routerProvider = Provider((ref) { path: "/login", name: WebViewLogin.name, parentNavigatorKey: rootNavigatorKey, - pageBuilder: (context, state) => SpotubePage( - child: kIsMobile ? const WebViewLogin() : const DesktopLoginPage(), - ), - ), - GoRoute( - path: "/login-tutorial", - name: LoginTutorial.name, - parentNavigatorKey: rootNavigatorKey, pageBuilder: (context, state) => const SpotubePage( - child: LoginTutorial(), + child: WebViewLogin(), ), ), GoRoute( diff --git a/lib/main.dart b/lib/main.dart index cb553115..b3a3132f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -65,7 +65,7 @@ Future main(List rawArgs) async { await FlutterDisplayMode.setHighRefreshRate(); } - if (kIsDesktop) { + if (kIsDesktop && !kIsMacOS) { await windowManager.setPreventClose(true); } diff --git a/lib/modules/desktop_login/login_form.dart b/lib/modules/desktop_login/login_form.dart deleted file mode 100644 index e5d31215..00000000 --- a/lib/modules/desktop_login/login_form.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/extensions/context.dart'; - -import 'package:spotube/provider/authentication/authentication.dart'; - -class TokenLoginForm extends HookConsumerWidget { - final void Function()? onDone; - const TokenLoginForm({ - super.key, - this.onDone, - }); - - @override - Widget build(BuildContext context, ref) { - final authenticationNotifier = ref.watch(authenticationProvider.notifier); - final directCodeController = useTextEditingController(); - - final isLoading = useState(false); - - return ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 400, - ), - child: Column( - children: [ - TextField( - controller: directCodeController, - decoration: InputDecoration( - hintText: context.l10n.spotify_cookie("\"sp_dc\""), - labelText: context.l10n.cookie_name_cookie("sp_dc"), - ), - keyboardType: TextInputType.visiblePassword, - ), - const SizedBox(height: 10), - FilledButton( - onPressed: isLoading.value - ? null - : () async { - try { - isLoading.value = true; - if (directCodeController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.fill_in_all_fields), - behavior: SnackBarBehavior.floating, - ), - ); - return; - } - final cookieHeader = - "sp_dc=${directCodeController.text.trim()}"; - - await authenticationNotifier.login(cookieHeader); - if (context.mounted) { - onDone?.call(); - } - } finally { - isLoading.value = false; - } - }, - child: Text(context.l10n.submit), - ) - ], - ), - ); - } -} diff --git a/lib/pages/desktop_login/desktop_login.dart b/lib/pages/desktop_login/desktop_login.dart deleted file mode 100644 index 80548898..00000000 --- a/lib/pages/desktop_login/desktop_login.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/modules/desktop_login/login_form.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/constrains.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/mobile_login/mobile_login.dart'; - -class DesktopLoginPage extends HookConsumerWidget { - static const name = WebViewLogin.name; - const DesktopLoginPage({super.key}); - - @override - Widget build(BuildContext context, ref) { - final mediaQuery = MediaQuery.of(context); - final theme = Theme.of(context); - final color = theme.colorScheme.surfaceVariant.withOpacity(.3); - - return SafeArea( - child: Scaffold( - appBar: const PageWindowTitleBar( - leading: BackButton(), - ), - body: SingleChildScrollView( - child: Center( - child: Container( - margin: const EdgeInsets.all(10), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(10), - ), - child: Column( - children: [ - Assets.spotubeLogoPng.image( - width: MediaQuery.of(context).size.width * - (mediaQuery.mdAndDown ? .5 : .3), - ), - Text( - context.l10n.add_spotify_credentials, - style: theme.textTheme.titleMedium, - ), - Text( - context.l10n.credentials_will_not_be_shared_disclaimer, - style: theme.textTheme.labelMedium, - ), - const SizedBox(height: 10), - TokenLoginForm( - onDone: () => GoRouter.of(context).go("/"), - ), - const SizedBox(height: 10), - Wrap( - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - Text(context.l10n.know_how_to_login), - TextButton( - child: Text( - context.l10n.follow_step_by_step_guide, - ), - onPressed: () => GoRouter.of(context).push( - "/login-tutorial", - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/pages/desktop_login/login_tutorial.dart b/lib/pages/desktop_login/login_tutorial.dart deleted file mode 100644 index ec62543c..00000000 --- a/lib/pages/desktop_login/login_tutorial.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:introduction_screen/introduction_screen.dart'; - -import 'package:spotube/collections/assets.gen.dart'; -import 'package:spotube/modules/desktop_login/login_form.dart'; -import 'package:spotube/components/links/hyper_link.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/context.dart'; -import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/provider/authentication/authentication.dart'; -import 'package:spotube/utils/service_utils.dart'; - -class LoginTutorial extends ConsumerWidget { - static const name = "login_tutorial"; - const LoginTutorial({super.key}); - - @override - Widget build(BuildContext context, ref) { - final auth = ref.watch(authenticationProvider); - final key = GlobalKey>(); - final theme = Theme.of(context); - - final pageDecoration = PageDecoration( - bodyTextStyle: theme.textTheme.bodyMedium!, - titleTextStyle: theme.textTheme.headlineMedium!, - ); - return Scaffold( - appBar: PageWindowTitleBar( - leading: TextButton( - child: Text(context.l10n.exit), - onPressed: () { - Navigator.of(context).pop(); - }, - ), - ), - body: IntroductionScreen( - key: key, - globalBackgroundColor: theme.scaffoldBackgroundColor, - overrideBack: OutlinedButton( - child: Center(child: Text(context.l10n.previous)), - onPressed: () { - (key.currentState as IntroductionScreenState).previous(); - }, - ), - overrideNext: FilledButton( - child: Center(child: Text(context.l10n.next)), - onPressed: () { - (key.currentState as IntroductionScreenState).next(); - }, - ), - showBackButton: true, - overrideDone: FilledButton( - onPressed: auth.asData?.value != null - ? () { - ServiceUtils.pushNamed(context, HomePage.name); - } - : null, - child: Center(child: Text(context.l10n.done)), - ), - pages: [ - PageViewModel( - decoration: pageDecoration, - title: context.l10n.step_1, - image: Assets.tutorial.step1.image(), - bodyWidget: Wrap( - children: [ - Text(context.l10n.first_go_to), - const SizedBox(width: 5), - const Hyperlink( - "accounts.spotify.com ", - "https://accounts.spotify.com", - ), - Text(context.l10n.login_if_not_logged_in), - ], - ), - ), - PageViewModel( - decoration: pageDecoration, - title: context.l10n.step_2, - image: Assets.tutorial.step2.image(), - bodyWidget: - Text(context.l10n.step_2_steps, textAlign: TextAlign.left), - ), - PageViewModel( - decoration: pageDecoration, - title: context.l10n.step_3, - image: Assets.tutorial.step3.image(), - bodyWidget: - Text(context.l10n.step_3_steps, textAlign: TextAlign.left), - ), - if (auth.asData?.value != null) - PageViewModel( - decoration: pageDecoration.copyWith( - bodyAlignment: Alignment.center, - ), - title: context.l10n.success_emoji, - image: Assets.success.image(), - body: context.l10n.success_message, - ) - else - PageViewModel( - decoration: pageDecoration, - title: context.l10n.step_4, - bodyWidget: Column( - children: [ - Text( - context.l10n.step_4_steps, - style: theme.textTheme.labelMedium, - ), - const SizedBox(height: 10), - TokenLoginForm( - onDone: () { - GoRouter.of(context).go("/"); - }, - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index b06a67f6..1ec488c9 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -1,4 +1,5 @@ import 'package:auto_size_text/auto_size_text.dart'; +import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -8,10 +9,12 @@ import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/pages/profile/profile.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/scrobbler/scrobbler.dart'; import 'package:spotube/provider/spotify/spotify.dart'; +import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class SettingsAccountSection extends HookConsumerWidget { @@ -23,6 +26,7 @@ class SettingsAccountSection extends HookConsumerWidget { final router = GoRouter.of(context); final auth = ref.watch(authenticationProvider); + final authNotifier = ref.watch(authenticationProvider.notifier); final scrobbler = ref.watch(scrobblerProvider); final me = ref.watch(meProvider); final meData = me.asData?.value; @@ -32,6 +36,36 @@ class SettingsAccountSection extends HookConsumerWidget { foregroundColor: Colors.white, ); + void onLogin() async { + if (kIsMobile) { + router.pushNamed(WebViewLogin.name); + return; + } + + final webview = await WebviewWindow.create(); + + webview.setOnUrlRequestCallback((url) { + final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); + + if (exp.hasMatch(url)) { + webview.getAllCookies().then((cookies) async { + final cookieHeader = + "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; + + await authNotifier.login(cookieHeader); + webview.close(); + if (context.mounted) { + context.go("/"); + } + }); + return true; + } + return false; + }); + + webview.launch("https://accounts.spotify.com/"); + } + return SectionCardWithHeading( heading: context.l10n.account, children: [ @@ -70,17 +104,11 @@ class SettingsAccountSection extends HookConsumerWidget { ), ), ), - onTap: constrains.mdAndUp - ? null - : () { - router.push("/login"); - }, + onTap: constrains.mdAndUp ? null : onLogin, trailing: constrains.smAndDown ? null : FilledButton( - onPressed: () { - router.push("/login"); - }, + onPressed: onLogin, style: ButtonStyle( shape: MaterialStateProperty.all( RoundedRectangleBorder( diff --git a/lib/provider/authentication/authentication.dart b/lib/provider/authentication/authentication.dart index 3ea8693b..08e658e8 100644 --- a/lib/provider/authentication/authentication.dart +++ b/lib/provider/authentication/authentication.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:collection/collection.dart'; +import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:drift/drift.dart'; @@ -160,6 +161,9 @@ class AuthenticationNotifier extends AsyncNotifier { WebStorageManager.instance().deleteAllData(); CookieManager.instance().deleteAllCookies(); } + if (kIsDesktop) { + await WebviewWindow.clearAll(); + } } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index b8e26367..2218d110 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,6 +7,7 @@ #include "generated_plugin_registrant.h" #include +#include #include #include #include @@ -24,6 +25,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) dart_discord_rpc_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DartDiscordRpcPlugin"); dart_discord_rpc_plugin_register_with_registrar(dart_discord_rpc_registrar); + g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin"); + desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 20d4a4dd..bb0776b5 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST dart_discord_rpc + desktop_webview_window file_selector_linux flutter_secure_storage_linux gtk diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 54546705..8a65bb53 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import app_links import audio_service import audio_session import bonsoir_darwin +import desktop_webview_window import device_info_plus import file_selector_macos import flutter_inappwebview_macos @@ -32,6 +33,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioServicePlugin.register(with: registry.registrar(forPlugin: "AudioServicePlugin")) AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) + DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 58d09cd9..9ab2ee38 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -8,6 +8,8 @@ PODS: - bonsoir_darwin (0.0.1): - Flutter - FlutterMacOS + - desktop_webview_window (0.0.1): + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS - file_selector_macos (0.0.1): @@ -70,6 +72,7 @@ DEPENDENCIES: - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/macos`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) - bonsoir_darwin (from `Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin`) + - desktop_webview_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) @@ -105,6 +108,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos bonsoir_darwin: :path: Flutter/ephemeral/.symlinks/plugins/bonsoir_darwin/darwin + desktop_webview_window: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_selector_macos: @@ -151,6 +156,7 @@ SPEC CHECKSUMS: audio_service: b88ff778e0e3915efd4cd1a5ad6f0beef0c950a9 audio_session: dea1f41890dbf1718f04a56f1d6150fd50039b72 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 + desktop_webview_window: 89bb3d691f4c80314a10be312f4cd35db93a9d5a device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d diff --git a/pubspec.lock b/pubspec.lock index 70b0655c..14f2a5b0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -474,6 +474,15 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + desktop_webview_window: + dependency: "direct main" + description: + path: "packages/desktop_webview_window" + ref: "feat/cookies" + resolved-ref: f20e433d4a948515b35089d40069f7dd9bced9e4 + url: "https://github.com/KRTirtho/flutter-plugins.git" + source: git + version: "0.2.4" device_info_plus: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a923f5a3..a9490746 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -24,6 +24,11 @@ dependencies: collection: ^1.15.0 curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 + desktop_webview_window: + git: + url: https://github.com/KRTirtho/flutter-plugins.git + ref: feat/cookies + path: packages/desktop_webview_window device_info_plus: ^10.1.0 dio: ^5.4.3+1 disable_battery_optimization: ^1.1.1 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b978edb9..4fcf3019 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -29,6 +30,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi")); DartDiscordRpcPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DartDiscordRpcPlugin")); + DesktopWebviewWindowPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4fcc467a..d0dd6751 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links bonsoir_windows dart_discord_rpc + desktop_webview_window file_selector_windows flutter_secure_storage_windows local_notifier From 1284b409e72d2e9e5ac2ed94f7884e38bd19657c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Jul 2024 11:31:47 +0600 Subject: [PATCH 164/261] chore: add linux dependencies and update CI + docker config --- .github/Dockerfile.flutter_distributor | 2 +- CONTRIBUTION.md | 6 +++--- cli/commands/install-dependencies.dart | 2 +- linux/packaging/deb/make_config.yaml | 2 ++ linux/packaging/rpm/make_config.yaml | 2 ++ 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/Dockerfile.flutter_distributor b/.github/Dockerfile.flutter_distributor index 952b9158..d842e533 100644 --- a/.github/Dockerfile.flutter_distributor +++ b/.github/Dockerfile.flutter_distributor @@ -4,7 +4,7 @@ ARG FLUTTER_VERSION RUN apt-get clean &&\ apt-get update &&\ - apt-get install -y bash curl file git unzip xz-utils zip libglu1-mesa cmake tar clang ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev rpm && \ + apt-get install -y bash curl file git unzip xz-utils zip libglu1-mesa cmake tar clang ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev rpm libwebkit2gtk-4.1-0 libwebkit2gtk-4.1-dev libsoup-3.0-0 libsoup-3.0-dev && \ rm -rf /var/lib/apt/lists/* WORKDIR /home/flutter diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index 0cfff0ca..d4746a1a 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -123,16 +123,16 @@ Do the following: - Install Development dependencies in linux - Debian (>=12/Bookworm)/Ubuntu ```bash - $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan + $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev avahi-daemon avahi-discover avahi-utils libnss-mdns mdns-scan libwebkit2gtk-4.1-0 libwebkit2gtk-4.1-dev libsoup-3.0-0 libsoup-3.0-dev ``` - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Arch/Manjaro ```bash - yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan + yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan webkit2gtk-4.1 libsoup3 ``` - Fedora ```bash - dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns + dnf install mpv mpv-devel libappindicator-gtk3 libappindicator-gtk3-devel libsecret libsecret-devel jsoncpp jsoncpp-devel libnotify libnotify-devel avahi mdns-scan nss-mdns webkit2gtk4.1 webkit2gtk4.1-devel libsoup3 libsoup3-devel ``` - Clone the Repo - Create a `.env` in root of the project following the `.env.example` template diff --git a/cli/commands/install-dependencies.dart b/cli/commands/install-dependencies.dart index 75df28df..6875e35f 100644 --- a/cli/commands/install-dependencies.dart +++ b/cli/commands/install-dependencies.dart @@ -37,7 +37,7 @@ class InstallDependenciesCommand extends Command { await shell.run( """ sudo apt-get update -y - sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev + sudo apt-get install -y tar clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev libwebkit2gtk-4.1-0 libwebkit2gtk-4.1-dev libsoup-3.0-0 libsoup-3.0-dev """, ); break; diff --git a/linux/packaging/deb/make_config.yaml b/linux/packaging/deb/make_config.yaml index 95777f56..a7bea1aa 100644 --- a/linux/packaging/deb/make_config.yaml +++ b/linux/packaging/deb/make_config.yaml @@ -23,6 +23,8 @@ dependencies: - avahi-utils - libnss-mdns - mdns-scan + - libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-0 + - libsoup-3.0-0 | libsoup-2.4-0 essential: false icon: assets/spotube-logo.png diff --git a/linux/packaging/rpm/make_config.yaml b/linux/packaging/rpm/make_config.yaml index 12b4473e..3d4a3b7e 100644 --- a/linux/packaging/rpm/make_config.yaml +++ b/linux/packaging/rpm/make_config.yaml @@ -16,6 +16,8 @@ requires: - avahi - mdns-scan - nss-mdns + - webkit2gtk4.1 + - libsoup3 display_name: Spotube From 79b842dad32d64ee5bd968f0c3552634b25f8daa Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Jul 2024 11:55:04 +0600 Subject: [PATCH 165/261] chore: use flutter 3.19.6 to avoid window stretching error in windows --- .github/workflows/spotube-release-binary.yml | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 5b74c9b5..e99aebab 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -20,7 +20,7 @@ on: description: Dry run without uploading to release env: - FLUTTER_VERSION: 3.22.1 + FLUTTER_VERSION: 3.19.6 permissions: contents: write diff --git a/pubspec.yaml b/pubspec.yaml index a9490746..58ca0ae9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -132,7 +132,7 @@ dependencies: encrypt: ^5.0.3 dev_dependencies: - build_runner: ^2.4.11 + build_runner: ^2.4.9 crypto: ^3.0.3 envied_generator: ^0.5.4+1 flutter_gen_runner: ^5.4.0 From f2f35bd2fbdd046441f4aa5da7f7572292a40b1a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Jul 2024 13:32:07 +0600 Subject: [PATCH 166/261] chore: fix windows webview2 trying to store data in admin folders --- lib/main.dart | 3 ++- lib/models/database/database.dart | 2 +- lib/pages/settings/sections/accounts.dart | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index b3a3132f..e0e34254 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dart_discord_rpc/dart_discord_rpc.dart'; +import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -65,7 +66,7 @@ Future main(List rawArgs) async { await FlutterDisplayMode.setHighRefreshRate(); } - if (kIsDesktop && !kIsMacOS) { + if (kIsDesktop) { await windowManager.setPreventClose(true); } diff --git a/lib/models/database/database.dart b/lib/models/database/database.dart index 1c233f84..fbfc59db 100644 --- a/lib/models/database/database.dart +++ b/lib/models/database/database.dart @@ -66,7 +66,7 @@ LazyDatabase _openConnection() { return LazyDatabase(() async { // put the database file, called db.sqlite here, into the documents folder // for your app. - final dbFolder = await getApplicationDocumentsDirectory(); + final dbFolder = await getApplicationSupportDirectory(); final file = File(join(dbFolder.path, 'db.sqlite')); // Also work around limitations on old Android versions diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 1ec488c9..0c2f3c5e 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -1,8 +1,12 @@ +import 'dart:io'; + import 'package:auto_size_text/auto_size_text.dart'; import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/image/universal_image.dart'; @@ -42,7 +46,18 @@ class SettingsAccountSection extends HookConsumerWidget { return; } - final webview = await WebviewWindow.create(); + final applicationSupportDir = await getApplicationSupportDirectory(); + final userDataFolder = Directory( + join(applicationSupportDir.path, "webview_window_Webview2")); + + if (!await userDataFolder.exists()) { + await userDataFolder.create(); + } + + final webview = await WebviewWindow.create( + configuration: + CreateConfiguration(userDataFolderWindows: userDataFolder.path), + ); webview.setOnUrlRequestCallback((url) { final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); From 7dd76d24c3c8082a0ac37a4018d807aaeca1c3d2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Jul 2024 14:54:49 +0600 Subject: [PATCH 167/261] chore: fix windows cookie invalid characters --- lib/main.dart | 6 ++++ lib/pages/settings/sections/accounts.dart | 44 ++++++++++++----------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index e0e34254..db7773f9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,6 +47,12 @@ import 'package:timezone/data/latest.dart' as tz; import 'package:window_manager/window_manager.dart'; Future main(List rawArgs) async { + WidgetsFlutterBinding.ensureInitialized(); + + if (runWebViewTitleBarWidget(rawArgs)) { + return; + } + final arguments = await startCLI(rawArgs); AppLogger.initialize(arguments["verbose"]); diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 0c2f3c5e..596599be 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -46,6 +46,7 @@ class SettingsAccountSection extends HookConsumerWidget { return; } + final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); final applicationSupportDir = await getApplicationSupportDirectory(); final userDataFolder = Directory( join(applicationSupportDir.path, "webview_window_Webview2")); @@ -55,30 +56,33 @@ class SettingsAccountSection extends HookConsumerWidget { } final webview = await WebviewWindow.create( - configuration: - CreateConfiguration(userDataFolderWindows: userDataFolder.path), + configuration: CreateConfiguration( + title: "Spotify Login", + titleBarTopPadding: kIsMacOS ? 20 : 0, + windowHeight: 720, + windowWidth: 1280, + userDataFolderWindows: userDataFolder.path, + ), ); + webview + ..setBrightness(theme.colorScheme.brightness) + ..launch("https://accounts.spotify.com/") + ..setOnUrlRequestCallback((url) { + if (exp.hasMatch(url)) { + webview.getAllCookies().then((cookies) async { + final cookieHeader = + "sp_dc=${cookies.firstWhere((element) => element.name.contains("sp_dc")).value.replaceAll("\u0000", "")}"; - webview.setOnUrlRequestCallback((url) { - final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); + await authNotifier.login(cookieHeader); + webview.close(); + if (context.mounted) { + context.go("/"); + } + }); + } - if (exp.hasMatch(url)) { - webview.getAllCookies().then((cookies) async { - final cookieHeader = - "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; - - await authNotifier.login(cookieHeader); - webview.close(); - if (context.mounted) { - context.go("/"); - } - }); return true; - } - return false; - }); - - webview.launch("https://accounts.spotify.com/"); + }); } return SectionCardWithHeading( From 359b918e6bb0a2c4792492b8cc84761af1c8aea4 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 5 Jul 2024 15:32:18 +0600 Subject: [PATCH 168/261] chore: fix windows playback not working for loop back ipv4 --- lib/main.dart | 10 ++++------ lib/services/audio_player/audio_player.dart | 5 +++-- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index db7773f9..45f4462d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,18 +47,16 @@ import 'package:timezone/data/latest.dart' as tz; import 'package:window_manager/window_manager.dart'; Future main(List rawArgs) async { - WidgetsFlutterBinding.ensureInitialized(); - - if (runWebViewTitleBarWidget(rawArgs)) { - return; - } - final arguments = await startCLI(rawArgs); AppLogger.initialize(arguments["verbose"]); AppLogger.runZoned(() async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); + if (runWebViewTitleBarWidget(rawArgs)) { + return; + } + await registerWindowsScheme("spotify"); tz.initializeTimeZones(); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index bb1a6203..7915dc3b 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -12,6 +12,7 @@ import 'package:media_kit/media_kit.dart' as mk; import 'package:spotube/services/audio_player/playback_state.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/utils/platform.dart'; part 'audio_players_streams_mixin.dart'; part 'audio_player_impl.dart'; @@ -28,7 +29,7 @@ class SpotubeMedia extends mk.Media { }) : super( track is LocalTrack ? track.path - : "http://${InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}", + : "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}", extras: { ...?extras, "track": switch (track) { @@ -42,7 +43,7 @@ class SpotubeMedia extends mk.Media { @override String get uri => track is LocalTrack ? (track as LocalTrack).path - : "http://${InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}"; + : "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}"; factory SpotubeMedia.fromMedia(mk.Media media) { final track = media.uri.startsWith("http") From 2f46fa32f13da3d500a3593e445c43f9a88ac54d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 6 Jul 2024 18:31:17 +0600 Subject: [PATCH 169/261] chore: fix webview and app window freezing after successful login --- lib/main.dart | 10 ++++++---- lib/pages/settings/sections/accounts.dart | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 45f4462d..7e8da0f2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -47,16 +47,18 @@ import 'package:timezone/data/latest.dart' as tz; import 'package:window_manager/window_manager.dart'; Future main(List rawArgs) async { + if (rawArgs.contains("web_view_title_bar")) { + WidgetsFlutterBinding.ensureInitialized(); + if (runWebViewTitleBarWidget(rawArgs)) { + return; + } + } final arguments = await startCLI(rawArgs); AppLogger.initialize(arguments["verbose"]); AppLogger.runZoned(() async { final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); - if (runWebViewTitleBarWidget(rawArgs)) { - return; - } - await registerWindowsScheme("spotify"); tz.initializeTimeZones(); diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 596599be..7e37b68b 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -74,6 +74,7 @@ class SettingsAccountSection extends HookConsumerWidget { "sp_dc=${cookies.firstWhere((element) => element.name.contains("sp_dc")).value.replaceAll("\u0000", "")}"; await authNotifier.login(cookieHeader); + webview.close(); if (context.mounted) { context.go("/"); From 2ce4853fd1e12ab4616a52d7827484bffd5012a6 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 6 Jul 2024 19:26:59 +0600 Subject: [PATCH 170/261] chore: fix while loading playlists/album already playing ones doesn't get cleared --- lib/provider/audio_player/audio_player.dart | 4 ++++ lib/provider/authentication/authentication.dart | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index da22b2ce..5323f3c0 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -287,6 +287,10 @@ class AudioPlayerNotifier extends Notifier { await ref.read(sourcedTrackProvider(intendedActiveTrack).future); } + if(medias.isEmpty) return; + + await removeCollections(state.collections); + await audioPlayer.openPlaylist( medias, initialIndex: initialIndex, diff --git a/lib/provider/authentication/authentication.dart b/lib/provider/authentication/authentication.dart index 08e658e8..f7339ef0 100644 --- a/lib/provider/authentication/authentication.dart +++ b/lib/provider/authentication/authentication.dart @@ -97,7 +97,7 @@ class AuthenticationNotifier extends AsyncNotifier { await database .into(database.authenticationTable) - .insert(refreshedCredentials); + .insertOnConflictUpdate(refreshedCredentials); } Future credentialsFromCookie( From ccea4a003d84853a68bffe39796b3d70bd34f0be Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 6 Jul 2024 21:35:56 +0600 Subject: [PATCH 171/261] fix: changed source doesn't get saved and uses the wrong once again --- lib/services/sourced_track/sources/jiosaavn.dart | 11 ++++++++++- lib/services/sourced_track/sources/piped.dart | 11 ++++++++++- lib/services/sourced_track/sources/youtube.dart | 15 +++++++++++++-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/services/sourced_track/sources/jiosaavn.dart b/lib/services/sourced_track/sources/jiosaavn.dart index 865e3d63..1434e4f7 100644 --- a/lib/services/sourced_track/sources/jiosaavn.dart +++ b/lib/services/sourced_track/sources/jiosaavn.dart @@ -43,7 +43,12 @@ class JioSaavnSourcedTrack extends SourcedTrack { }) async { final database = ref.read(databaseProvider); final cachedSource = await (database.select(database.sourceMatchTable) - ..where((s) => s.trackId.equals(track.id!))) + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) .getSingleOrNull(); if (cachedSource == null || @@ -215,7 +220,11 @@ class JioSaavnSourcedTrack extends SourcedTrack { trackId: id!, sourceId: info.id, sourceType: const Value(SourceType.jiosaavn), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), ), + mode: InsertMode.replace, ); return JioSaavnSourcedTrack( diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart index d156b26e..d24f110f 100644 --- a/lib/services/sourced_track/sources/piped.dart +++ b/lib/services/sourced_track/sources/piped.dart @@ -52,7 +52,12 @@ class PipedSourcedTrack extends SourcedTrack { }) async { final database = ref.read(databaseProvider); final cachedSource = await (database.select(database.sourceMatchTable) - ..where((s) => s.trackId.equals(track.id!))) + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) .getSingleOrNull(); final preferences = ref.read(userPreferencesProvider); final pipedClient = ref.read(pipedProvider); @@ -278,7 +283,11 @@ class PipedSourcedTrack extends SourcedTrack { trackId: id!, sourceId: newSourceInfo.id, sourceType: const Value(SourceType.youtube), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), ), + mode: InsertMode.replace, ); return PipedSourcedTrack( diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart index 0501a499..0b5ee71b 100644 --- a/lib/services/sourced_track/sources/youtube.dart +++ b/lib/services/sourced_track/sources/youtube.dart @@ -50,8 +50,14 @@ class YoutubeSourcedTrack extends SourcedTrack { }) async { final database = ref.read(databaseProvider); final cachedSource = await (database.select(database.sourceMatchTable) - ..where((s) => s.trackId.equals(track.id!))) - .getSingleOrNull(); + ..where((s) => s.trackId.equals(track.id!)) + ..limit(1) + ..orderBy([ + (s) => + OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc), + ])) + .get() + .then((s) => s.firstOrNull); if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) { final siblings = await fetchSiblings(ref: ref, track: track); @@ -287,12 +293,17 @@ class YoutubeSourcedTrack extends SourcedTrack { ); final database = ref.read(databaseProvider); + await database.into(database.sourceMatchTable).insert( SourceMatchTableCompanion.insert( trackId: id!, sourceId: newSourceInfo.id, sourceType: const Value(SourceType.youtube), + // Because we're sorting by createdAt in the query + // we have to update it to indicate priority + createdAt: Value(DateTime.now()), ), + mode: InsertMode.replace, ); return YoutubeSourcedTrack( From 86f5b80177b5c1d3da2ff9fbe7694165c48ac190 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 6 Jul 2024 21:38:36 +0600 Subject: [PATCH 172/261] chore: fix insert failing to invalid conflict check --- lib/provider/authentication/authentication.dart | 2 +- lib/provider/spotify/lyrics/synced.dart | 3 ++- lib/provider/spotify/spotify.dart | 1 + lib/utils/migrations/hive.dart | 9 ++++++--- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/provider/authentication/authentication.dart b/lib/provider/authentication/authentication.dart index f7339ef0..05a05972 100644 --- a/lib/provider/authentication/authentication.dart +++ b/lib/provider/authentication/authentication.dart @@ -97,7 +97,7 @@ class AuthenticationNotifier extends AsyncNotifier { await database .into(database.authenticationTable) - .insertOnConflictUpdate(refreshedCredentials); + .insert(refreshedCredentials, mode: InsertMode.replace); } Future credentialsFromCookie( diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index bcf2a162..085fccb7 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -152,11 +152,12 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier { } if (cachedLyrics == null || cachedLyrics.lyrics.isEmpty) { - await database.into(database.lyricsTable).insertOnConflictUpdate( + await database.into(database.lyricsTable).insert( LyricsTableCompanion.insert( trackId: track.id!, data: lyrics, ), + mode: InsertMode.replace, ); } diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index 63a8ed38..5997a47a 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -2,6 +2,7 @@ library spotify; import 'dart:async'; +import 'package:drift/drift.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/spotify/utils/json_cast.dart'; diff --git a/lib/utils/migrations/hive.dart b/lib/utils/migrations/hive.dart index e43df1d8..e5781931 100644 --- a/lib/utils/migrations/hive.dart +++ b/lib/utils/migrations/hive.dart @@ -35,13 +35,14 @@ Future migrateAuthenticationInfo() async { if (credentials == null) return; - await _database.into(_database.authenticationTable).insertOnConflictUpdate( + await _database.into(_database.authenticationTable).insert( AuthenticationTableCompanion.insert( accessToken: DecryptedText(credentials.accessToken), cookie: DecryptedText(credentials.cookie), expiration: credentials.expiration, id: const Value(0), ), + mode: InsertMode.insertOrReplace, ); AppLogger.log.i("✅ Migrated authentication info"); @@ -58,7 +59,7 @@ Future migratePreferences() async { if (preferences == null) return; - await _database.into(_database.preferencesTable).insertOnConflictUpdate( + await _database.into(_database.preferencesTable).insert( PreferencesTableCompanion.insert( id: const Value(0), accentColorScheme: Value(preferences.accentColorScheme), @@ -108,6 +109,7 @@ Future migratePreferences() async { systemTitleBar: Value(preferences.systemTitleBar), themeMode: Value(preferences.themeMode), ), + mode: InsertMode.replace, ); AppLogger.log.i("✅ Migrated preferences"); @@ -235,12 +237,13 @@ Future migrateLastFmCredentials() async { if (data == null) return; - await _database.into(_database.scrobblerTable).insertOnConflictUpdate( + await _database.into(_database.scrobblerTable).insert( ScrobblerTableCompanion.insert( id: const Value(0), passwordHash: DecryptedText(data.passwordHash), username: data.username, ), + mode: InsertMode.replace, ); AppLogger.log.i("✅ Migrated Last.fm credentials"); From 86ee64c6066b3aa3c5910c89ffff7367826e823c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 6 Jul 2024 22:02:31 +0600 Subject: [PATCH 173/261] chore: remove old logger --- lib/collections/intents.dart | 3 - lib/extensions/list.dart | 99 ------------------- lib/models/logger.dart | 74 -------------- lib/modules/artist/artist_album_list.dart | 6 +- lib/modules/player/player_actions.dart | 5 +- lib/modules/player/player_controls.dart | 5 +- lib/modules/root/bottom_player.dart | 4 +- lib/pages/artist/artist.dart | 5 +- lib/pages/settings/logs.dart | 4 +- lib/provider/connect/connect.dart | 8 +- lib/provider/server/routes/connect.dart | 8 +- lib/services/cli/cli.dart | 8 -- .../download_manager/chunked_download.dart | 7 -- .../download_manager/download_manager.dart | 13 +-- lib/utils/service_utils.dart | 9 +- 15 files changed, 19 insertions(+), 239 deletions(-) delete mode 100644 lib/extensions/list.dart delete mode 100644 lib/models/logger.dart diff --git a/lib/collections/intents.dart b/lib/collections/intents.dart index ac0451ac..4f446831 100644 --- a/lib/collections/intents.dart +++ b/lib/collections/intents.dart @@ -6,7 +6,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/modules/player/player_controls.dart'; -import 'package:spotube/models/logger.dart'; import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; @@ -21,8 +20,6 @@ class PlayPauseIntent extends Intent { } class PlayPauseAction extends Action { - final logger = getLogger(PlayPauseAction); - @override invoke(intent) async { if (PlayerControls.focusNode.canRequestFocus) { diff --git a/lib/extensions/list.dart b/lib/extensions/list.dart deleted file mode 100644 index 6ecf6cf6..00000000 --- a/lib/extensions/list.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:spotube/models/logger.dart'; - -final logger = getLogger("List"); - -extension MultiSortListMap on List { - /// [preference] - List of properties in which you want to sort the list - /// i.e. - /// ``` - /// List preference = ['property1','property2']; - /// ``` - /// This will first sort the list by property1 then by property2 - /// - /// [criteria] - List of booleans that specifies the criteria of sort - /// i.e., For ascending order `true` and for descending order `false`. - /// ``` - /// List criteria = [true. false]; - /// ``` - List sortByProperties(List criteria, List preference) { - if (preference.isEmpty || criteria.isEmpty || isEmpty) { - return this; - } - if (preference.length != criteria.length) { - logger.d('Criteria length is not equal to preference'); - return this; - } - - int compare(int i, Map a, Map b) { - if (a[preference[i]] == b[preference[i]]) { - return 0; - } else if (a[preference[i]] > b[preference[i]]) { - return criteria[i] ? 1 : -1; - } else { - return criteria[i] ? -1 : 1; - } - } - - int sortAll(Map a, Map b) { - int i = 0; - int result = 0; - while (i < preference.length) { - result = compare(i, a, b); - if (result != 0) break; - i++; - } - return result; - } - - return sorted((a, b) => sortAll(a, b)); - } -} - -extension MultiSortListTupleMap on List<(Map, V)> { - /// [preference] - List of properties in which you want to sort the list - /// i.e. - /// ``` - /// List preference = ['property1','property2']; - /// ``` - /// This will first sort the list by property1 then by property2 - /// - /// [criteria] - List of booleans that specifies the criteria of sort - /// i.e., For ascending order `true` and for descending order `false`. - /// ``` - /// List criteria = [true. false]; - /// ``` - List<(Map, V)> sortByProperties( - List criteria, List preference) { - if (preference.isEmpty || criteria.isEmpty || isEmpty) { - return this; - } - if (preference.length != criteria.length) { - logger.d('Criteria length is not equal to preference'); - return this; - } - - int compare(int i, (Map, V) a, (Map, V) b) { - if (a.$1[preference[i]] == b.$1[preference[i]]) { - return 0; - } else if (a.$1[preference[i]] > b.$1[preference[i]]) { - return criteria[i] ? 1 : -1; - } else { - return criteria[i] ? -1 : 1; - } - } - - int sortAll((Map, V) a, (Map, V) b) { - int i = 0; - int result = 0; - while (i < preference.length) { - result = compare(i, a, b); - if (result != 0) break; - i++; - } - return result; - } - - return sorted((a, b) => sortAll(a, b)); - } -} diff --git a/lib/models/logger.dart b/lib/models/logger.dart deleted file mode 100644 index 3236028d..00000000 --- a/lib/models/logger.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:logger/logger.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:path/path.dart' as path; -import 'package:spotube/utils/platform.dart'; - -final _loggerFactory = SpotubeLogger(); -final logEnv = { - if (!kIsWeb) ...Platform.environment, -}; - -SpotubeLogger getLogger(T owner) { - _loggerFactory.owner = owner is String ? owner : owner.toString(); - return _loggerFactory; -} - -Future getLogsPath() async { - String dir = (await getApplicationDocumentsDirectory()).path; - if (kIsAndroid) { - dir = (await getExternalStorageDirectory())?.path ?? ""; - } - - if (kIsMacOS) { - dir = path.join((await getLibraryDirectory()).path, "Logs"); - } - final file = File(path.join(dir, ".spotube_logs")); - if (!await file.exists()) { - await file.create(recursive: true); - } - return file; -} - -class SpotubeLogger extends Logger { - String? owner; - SpotubeLogger([this.owner]) : super(filter: _SpotubeLogFilter()); - - @override - void log(Level level, dynamic message, - {Object? error, StackTrace? stackTrace, DateTime? time}) async { - if (!kIsWeb) { - if (level == Level.error) { - String dir = (await getApplicationDocumentsDirectory()).path; - - if (kIsAndroid) { - dir = (await getExternalStorageDirectory())?.path ?? ""; - } - - if (kIsMacOS) { - dir = path.join((await getLibraryDirectory()).path, "Logs"); - } - - await File(path.join(dir, ".spotube_logs")).writeAsString( - "[${DateTime.now()}]\n$message\n$stackTrace", - mode: FileMode.writeOnlyAppend); - } - } - - super.log(level, "[$owner] $message", error: error, stackTrace: stackTrace); - } -} - -class _SpotubeLogFilter extends DevelopmentFilter { - @override - bool shouldLog(LogEvent event) { - if ((logEnv["DEBUG"] == "true" && event.level == Level.debug) || - (logEnv["VERBOSE"] == "true" && event.level == Level.trace) || - (logEnv["ERROR"] == "true" && event.level == Level.error)) { - return true; - } - return super.shouldLog(event); - } -} diff --git a/lib/modules/artist/artist_album_list.dart b/lib/modules/artist/artist_album_list.dart index 9bb65804..a2dd8006 100644 --- a/lib/modules/artist/artist_album_list.dart +++ b/lib/modules/artist/artist_album_list.dart @@ -3,18 +3,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/spotify/spotify.dart'; class ArtistAlbumList extends HookConsumerWidget { final String artistId; - ArtistAlbumList( + + const ArtistAlbumList( this.artistId, { super.key, }); - final logger = getLogger(ArtistAlbumList); - @override Widget build(BuildContext context, ref) { final albumsQuery = ref.watch(artistAlbumsProvider(artistId)); diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart index 8a7b3e83..a47c992d 100644 --- a/lib/modules/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -11,7 +11,6 @@ import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; -import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; @@ -22,14 +21,14 @@ class PlayerActions extends HookConsumerWidget { final bool floatingQueue; final bool showQueue; final List? extraActions; - PlayerActions({ + + const PlayerActions({ this.mainAxisAlignment = MainAxisAlignment.center, this.floatingQueue = true, this.showQueue = true, this.extraActions, super.key, }); - final logger = getLogger(PlayerActions); @override Widget build(BuildContext context, ref) { diff --git a/lib/modules/player/player_controls.dart b/lib/modules/player/player_controls.dart index 1b9d9f86..c88f6258 100644 --- a/lib/modules/player/player_controls.dart +++ b/lib/modules/player/player_controls.dart @@ -10,7 +10,6 @@ import 'package:spotube/collections/intents.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/modules/player/use_progress.dart'; -import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -19,14 +18,12 @@ class PlayerControls extends HookConsumerWidget { final PaletteGenerator? palette; final bool compact; - PlayerControls({ + const PlayerControls({ this.palette, this.compact = false, super.key, }); - final logger = getLogger(PlayerControls); - static FocusNode focusNode = FocusNode(); @override diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart index e7dbacd2..23904aef 100644 --- a/lib/modules/root/bottom_player.dart +++ b/lib/modules/root/bottom_player.dart @@ -16,7 +16,6 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; -import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; @@ -27,9 +26,8 @@ import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; class BottomPlayer extends HookConsumerWidget { - BottomPlayer({super.key}); + const BottomPlayer({super.key}); - final logger = getLogger(BottomPlayer); @override Widget build(BuildContext context, ref) { final auth = ref.watch(authenticationProvider); diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 04389ffc..70ad72de 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -7,7 +7,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/artist/artist_album_list.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/logger.dart'; + import 'package:spotube/pages/artist/section/footer.dart'; import 'package:spotube/pages/artist/section/header.dart'; import 'package:spotube/pages/artist/section/related_artists.dart'; @@ -18,8 +18,7 @@ class ArtistPage extends HookConsumerWidget { static const name = "artist"; final String artistId; - final logger = getLogger(ArtistPage); - ArtistPage(this.artistId, {super.key}); + const ArtistPage(this.artistId, {super.key}); @override Widget build(BuildContext context, ref) { diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index 65e4c82e..a49050ad 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -8,7 +8,7 @@ import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/models/logger.dart'; +import 'package:spotube/services/logger/logger.dart'; class LogsPage extends HookWidget { static const name = "logs"; @@ -61,7 +61,7 @@ class LogsPage extends HookWidget { useEffect(() { final timer = Timer.periodic(const Duration(seconds: 5), (t) async { - path.value ??= await getLogsPath(); + path.value ??= await AppLogger.getLogsPath(); final raw = await path.value!.readAsString(); final hasChanged = rawLogs.value != raw; rawLogs.value = raw; diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart index 28eb131b..000a28af 100644 --- a/lib/provider/connect/connect.dart +++ b/lib/provider/connect/connect.dart @@ -7,7 +7,7 @@ import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart' hide Playlist; import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/models/logger.dart'; + import 'package:spotube/provider/connect/clients.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/status.dart' as status; @@ -46,8 +46,6 @@ final volumeProvider = StateProvider( (ref) => 1.0, ); -final logger = getLogger('ConnectNotifier'); - class ConnectNotifier extends AsyncNotifier { @override build() async { @@ -58,7 +56,7 @@ class ConnectNotifier extends AsyncNotifier { final service = connectClients.asData!.value.resolvedService!; - logger.t( + AppLogger.log.t( '♾️ Connecting to ${service.name}: ws://${service.host}:${service.port}/ws', ); @@ -68,7 +66,7 @@ class ConnectNotifier extends AsyncNotifier { await channel.ready; - logger.t( + AppLogger.log.t( '✅ Connected to ${service.name}: ws://${service.host}:${service.port}/ws', ); diff --git a/lib/provider/server/routes/connect.dart b/lib/provider/server/routes/connect.dart index 8e75a87e..0d35b473 100644 --- a/lib/provider/server/routes/connect.dart +++ b/lib/provider/server/routes/connect.dart @@ -7,7 +7,7 @@ import 'package:shelf/shelf.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/models/connect/connect.dart'; -import 'package:spotube/models/logger.dart'; + import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/volume_provider.dart'; @@ -25,11 +25,9 @@ class ServerConnectRoutes { final Ref ref; final StreamController _connectClientStreamController; final List subscriptions; - final SpotubeLogger logger; ServerConnectRoutes(this.ref) : _connectClientStreamController = StreamController.broadcast(), - subscriptions = [], - logger = getLogger('ConnectServer') { + subscriptions = [] { ref.onDispose(() { _connectClientStreamController.close(); for (final subscription in subscriptions) { @@ -193,7 +191,7 @@ class ServerConnectRoutes { } }, onDone: () { - logger.i('Connection closed'); + AppLogger.log.i('Connection closed'); }, ), ]); diff --git a/lib/services/cli/cli.dart b/lib/services/cli/cli.dart index 720216c7..985c0e72 100644 --- a/lib/services/cli/cli.dart +++ b/lib/services/cli/cli.dart @@ -5,7 +5,6 @@ import 'dart:io'; import 'package:args/args.dart'; import 'package:flutter/foundation.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:spotube/models/logger.dart'; Future startCLI(List args) async { final parser = ArgParser(); @@ -15,13 +14,6 @@ Future startCLI(List args) async { abbr: 'v', help: 'Verbose mode', defaultsTo: !kReleaseMode, - callback: (verbose) { - if (verbose) { - logEnv['VERBOSE'] = 'true'; - logEnv['DEBUG'] = 'true'; - logEnv['ERROR'] = 'true'; - } - }, ); parser.addFlag( "version", diff --git a/lib/services/download_manager/chunked_download.dart b/lib/services/download_manager/chunked_download.dart index 9e5e0a98..80a3e78f 100644 --- a/lib/services/download_manager/chunked_download.dart +++ b/lib/services/download_manager/chunked_download.dart @@ -2,9 +2,6 @@ import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; -import 'package:spotube/models/logger.dart'; - -final logger = getLogger("ChunkedDownload"); /// Downloading by spiting as file in chunks extension ChunkDownload on Dio { @@ -69,11 +66,7 @@ extension ChunkDownload on Dio { } await raf.close(); - logger.d("Downloaded file path: ${headFile.path}"); - headFile = await headFile.rename(savePath); - - logger.d("Renamed file path: ${headFile.path}"); } final firstResponse = await downloadChunk( diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart index afbee88c..d2072bd7 100644 --- a/lib/services/download_manager/download_manager.dart +++ b/lib/services/download_manager/download_manager.dart @@ -1,18 +1,18 @@ import 'dart:async'; import 'dart:collection'; import 'dart:io'; -import 'package:spotube/services/logger/logger.dart'; import 'package:collection/collection.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; -import 'package:spotube/models/logger.dart'; + import 'package:spotube/services/download_manager/chunked_download.dart'; import 'package:spotube/services/download_manager/download_request.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/download_manager/download_task.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/utils/primitive_utils.dart'; export './download_request.dart'; @@ -25,7 +25,6 @@ typedef DownloadStatusEvent = ({ }); class DownloadManager { - final logger = getLogger("DownloadManager"); final Map _cache = {}; final Queue _queue = Queue(); var dio = Dio(); @@ -77,7 +76,6 @@ class DownloadManager { } setStatus(task, DownloadStatus.downloading); - logger.d("[DownloadManager] $url"); final file = File(savePath.toString()); await Directory(path.dirname(savePath)).create(recursive: true); @@ -99,11 +97,8 @@ class DownloadManager { final partialFileExist = await partialFile.exists(); if (fileExist) { - logger.d("[DownloadManager] File Exists"); setStatus(task, DownloadStatus.completed); } else if (partialFileExist) { - logger.d("[DownloadManager] Partial File Exists"); - final partialFileLength = await partialFile.length(); final response = await dio.download( @@ -225,7 +220,6 @@ class DownloadManager { } Future pauseDownload(String url) async { - logger.d("[DownloadManager] Pause Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.paused); task.request.cancelToken.cancel(); @@ -234,7 +228,6 @@ class DownloadManager { } Future cancelDownload(String url) async { - logger.d("[DownloadManager] Cancel Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.canceled); _queue.remove(task.request); @@ -242,7 +235,6 @@ class DownloadManager { } Future resumeDownload(String url) async { - logger.d("[DownloadManager] Resume Download"); var task = getDownload(url)!; setStatus(task, DownloadStatus.downloading); task.request.cancelToken = CancelToken(); @@ -405,7 +397,6 @@ class DownloadManager { while (_queue.isNotEmpty && runningTasks < maxConcurrentTasks) { runningTasks++; - logger.d('Concurrent workers: $runningTasks'); var currentRequest = _queue.removeFirst(); await download( diff --git a/lib/utils/service_utils.dart b/lib/utils/service_utils.dart index 5950bc8c..c00f07ab 100644 --- a/lib/utils/service_utils.dart +++ b/lib/utils/service_utils.dart @@ -4,7 +4,7 @@ import 'package:html/dom.dart' hide Text; import 'package:spotify/spotify.dart'; import 'package:spotube/modules/library/user_local_tracks.dart'; import 'package:spotube/modules/root/update_dialog.dart'; -import 'package:spotube/models/logger.dart'; + import 'package:spotube/models/lyrics.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/services/dio/dio.dart'; @@ -24,8 +24,6 @@ import 'package:spotube/collections/env.dart'; import 'package:version/version.dart'; abstract class ServiceUtils { - static final logger = getLogger("ServiceUtils"); - static final _englishMatcherRegex = RegExp( "^[a-zA-Z0-9\\s!\"#\$%&\\'()*+,-.\\/:;<=>?@\\[\\]^_`{|}~]*\$", ); @@ -194,8 +192,6 @@ abstract class ServiceUtils { artists: artistNames, ); - logger.v("[Searching Subtitle] $query"); - final searchUri = Uri.parse("$baseUri/subtitles4songs.aspx").replace( queryParameters: {"q": query}, ); @@ -227,7 +223,6 @@ abstract class ServiceUtils { // not result was found at all if (rateSortedResults.first["points"] == 0) { - logger.e("[Subtitle not found] ${track.name}"); return Future.error("Subtitle lookup failed", StackTrace.current); } @@ -235,8 +230,6 @@ abstract class ServiceUtils { final subtitleUri = Uri.parse("$baseUri/${topResult.attributes["href"]}&type=lrc"); - logger.v("[Selected subtitle] ${topResult.text} | $subtitleUri"); - final lrcDocument = parser.parse((await globalDio.getUri( subtitleUri, options: Options(responseType: ResponseType.plain), From d359d130de628bb1b67bd43d14a18d6d6e6d0ff6 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 6 Jul 2024 22:08:49 +0600 Subject: [PATCH 174/261] chore: resize currently downloading --- lib/modules/library/user_downloads.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/modules/library/user_downloads.dart b/lib/modules/library/user_downloads.dart index a5f3883a..7fe9800c 100644 --- a/lib/modules/library/user_downloads.dart +++ b/lib/modules/library/user_downloads.dart @@ -31,7 +31,7 @@ class UserDownloads extends HookConsumerWidget { context.l10n .currently_downloading(downloadManager.$downloadCount), maxLines: 1, - style: Theme.of(context).textTheme.headlineMedium, + style: Theme.of(context).textTheme.titleMedium, ), ), const SizedBox(width: 10), From 67ae2e81596dfac850d036f5a1a98f8d4c16ad74 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Jul 2024 21:05:28 +0600 Subject: [PATCH 175/261] chore: fix remote playback not working --- lib/provider/audio_player/state.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/provider/audio_player/state.dart b/lib/provider/audio_player/state.dart index 387c2e30..0e3004f5 100644 --- a/lib/provider/audio_player/state.dart +++ b/lib/provider/audio_player/state.dart @@ -40,6 +40,7 @@ class AudioPlayerState { httpHeaders: media['httpHeaders'], )), ) + .cast() .toList(), index: json['playlist']['index'], ), From c7d8ed567a9bd9b23098ecab7ed923e30f66f72f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Jul 2024 21:17:49 +0600 Subject: [PATCH 176/261] fix: lyrics page doesn't scroll to top after song ends #885 --- lib/pages/lyrics/synced_lyrics.dart | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 21796725..c2bf7b81 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; @@ -56,7 +58,11 @@ class SyncedLyrics extends HookConsumerWidget { ref.listen( audioPlayerProvider.select((s) => s.activeTrack), (previous, next) { - controller.scrollToIndex(0); + controller.animateTo( + 0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); ref.read(syncedLyricsDelayProvider.notifier).state = 0; }, ); @@ -69,6 +75,23 @@ class SyncedLyrics extends HookConsumerWidget { final bodyTextTheme = textTheme.bodyLarge?.copyWith( color: palette.bodyTextColor, ); + + useEffect(() { + StreamSubscription? subscription; + WidgetsBinding.instance.addPostFrameCallback((_) { + subscription = audioPlayer.positionStream.listen((event) { + if (event > Duration.zero) return; + controller.animateTo( + 0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + }); + }); + + return subscription?.cancel; + }, [controller]); + return Stack( children: [ CustomScrollView( From 44861a9f5c80625e0ff9a04dc9752dc186a0fa73 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Jul 2024 22:13:05 +0600 Subject: [PATCH 177/261] fix: unescape html escape values #1300 --- lib/components/playbutton_card.dart | 17 ++++--------- .../sections/header/flexible_header.dart | 10 ++++---- lib/extensions/string.dart | 2 +- .../playlist/playlist_create_dialog.dart | 3 ++- lib/modules/stats/common/playlist_item.dart | 4 ++-- lib/pages/playlist/playlist.dart | 21 +++++++++++++--- lib/provider/spotify/playlist/favorite.dart | 14 +++++++++++ lib/provider/spotify/playlist/playlist.dart | 24 +++++++++++++++---- 8 files changed, 65 insertions(+), 30 deletions(-) diff --git a/lib/components/playbutton_card.dart b/lib/components/playbutton_card.dart index ffd91cd2..a0b96ab8 100644 --- a/lib/components/playbutton_card.dart +++ b/lib/components/playbutton_card.dart @@ -8,18 +8,10 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/hover_builder.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/string.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; -final htmlTagRegexp = RegExp(r"<[^>]*>", caseSensitive: true); - -String? useDescription(String? description) { - return useMemoized(() { - if (description == null) return null; - return description.replaceAll(htmlTagRegexp, ''); - }, [description]); -} - class PlaybuttonCard extends HookWidget { final void Function()? onTap; final void Function()? onPlaybuttonPressed; @@ -66,8 +58,7 @@ class PlaybuttonCard extends HookWidget { others: 15, ); - final cleanDescription = useDescription(description); - + var unescapeHtml = description?.unescapeHtml(); return Container( constraints: BoxConstraints(maxWidth: size), margin: margin, @@ -205,11 +196,11 @@ class PlaybuttonCard extends HookWidget { overflow: TextOverflow.ellipsis, ), ), - if (cleanDescription != null) + if (description != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: AutoSizeText( - cleanDescription, + unescapeHtml!, maxLines: 2, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withOpacity(.5), diff --git a/lib/components/tracks_view/sections/header/flexible_header.dart b/lib/components/tracks_view/sections/header/flexible_header.dart index 6e8fc2d1..6845cc3e 100644 --- a/lib/components/tracks_view/sections/header/flexible_header.dart +++ b/lib/components/tracks_view/sections/header/flexible_header.dart @@ -5,12 +5,12 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/playbutton_card.dart'; import 'package:spotube/components/tracks_view/sections/header/header_actions.dart'; import 'package:spotube/components/tracks_view/sections/header/header_buttons.dart'; import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:gap/gap.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/string.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/utils/platform.dart'; @@ -24,8 +24,6 @@ class TrackViewFlexHeader extends HookConsumerWidget { final defaultTextStyle = DefaultTextStyle.of(context); final mediaQuery = MediaQuery.of(context); - final description = useDescription(props.description); - final palette = usePaletteColor(props.image, ref); return IconTheme( @@ -127,10 +125,10 @@ class TrackViewFlexHeader extends HookConsumerWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 10), - if (description != null && - description.isNotEmpty) + if (props.description != null && + props.description!.isNotEmpty) Text( - description, + props.description!.unescapeHtml(), style: defaultTextStyle.style.copyWith( color: palette.bodyTextColor, diff --git a/lib/extensions/string.dart b/lib/extensions/string.dart index 0aa41dc6..d3706f3f 100644 --- a/lib/extensions/string.dart +++ b/lib/extensions/string.dart @@ -7,7 +7,7 @@ extension UnescapeHtml on String { } extension NullableUnescapeHtml on String? { - String? unescapeHtml() => this == null ? null : htmlEscape.convert(this!); + String? unescapeHtml() => this?.unescapeHtml(); } extension StringExtension on String { diff --git a/lib/modules/playlist/playlist_create_dialog.dart b/lib/modules/playlist/playlist_create_dialog.dart index 6c333986..b9e4be8f 100644 --- a/lib/modules/playlist/playlist_create_dialog.dart +++ b/lib/modules/playlist/playlist_create_dialog.dart @@ -15,6 +15,7 @@ import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/string.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; @@ -54,7 +55,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { text: updatingPlaylist?.name, ); final description = useTextEditingController( - text: updatingPlaylist?.description, + text: updatingPlaylist?.description?.unescapeHtml(), ); final public = useState( updatingPlaylist?.public ?? false, diff --git a/lib/modules/stats/common/playlist_item.dart b/lib/modules/stats/common/playlist_item.dart index 79e40d71..515c97b3 100644 --- a/lib/modules/stats/common/playlist_item.dart +++ b/lib/modules/stats/common/playlist_item.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/string.dart'; import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -28,7 +28,7 @@ class StatsPlaylistItem extends StatelessWidget { ), title: Text(playlist.name!), subtitle: Text( - playlist.description!.replaceAll(htmlTagRegexp, ''), + playlist.description?.unescapeHtml() ?? '', maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index f7c5a431..31426f20 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -12,19 +13,33 @@ import 'package:spotube/provider/spotify/spotify.dart'; class PlaylistPage extends HookConsumerWidget { static const name = "playlist"; - final PlaylistSimple playlist; + final PlaylistSimple _playlist; const PlaylistPage({ super.key, - required this.playlist, - }); + required PlaylistSimple playlist, + }) : _playlist = playlist; @override Widget build(BuildContext context, ref) { + final playlist = ref + .watch( + favoritePlaylistsProvider.select( + (value) => value.whenData( + (value) => + value.items.firstWhereOrNull((s) => s.id == _playlist.id), + ), + ), + ) + .asData + ?.value ?? + _playlist; + final tracks = ref.watch(playlistTracksProvider(playlist.id!)); final tracksNotifier = ref.watch(playlistTracksProvider(playlist.id!).notifier); final isFavoritePlaylist = ref.watch(isFavoritePlaylistProvider(playlist.id!)); + final favoritePlaylistsNotifier = ref.watch(favoritePlaylistsProvider.notifier); diff --git a/lib/provider/spotify/playlist/favorite.dart b/lib/provider/spotify/playlist/favorite.dart index a0e051aa..000001ad 100644 --- a/lib/provider/spotify/playlist/favorite.dart +++ b/lib/provider/spotify/playlist/favorite.dart @@ -51,6 +51,20 @@ class FavoritePlaylistsNotifier ); } + void updatePlaylist(PlaylistSimple playlist) { + if (state.value == null) return; + + if (state.value!.items.none((e) => e.id == playlist.id)) return; + + state = AsyncData( + state.value!.copyWith( + items: state.value!.items + .map((element) => element.id == playlist.id ? playlist : element) + .toList(), + ), + ); + } + Future addFavorite(PlaylistSimple playlist) async { await update((state) async { await spotify.playlists.followPlaylist(playlist.id!); diff --git a/lib/provider/spotify/playlist/playlist.dart b/lib/provider/spotify/playlist/playlist.dart index fd420cd9..0eec3a87 100644 --- a/lib/provider/spotify/playlist/playlist.dart +++ b/lib/provider/spotify/playlist/playlist.dart @@ -71,16 +71,32 @@ class PlaylistNotifier extends FamilyAsyncNotifier { state.id!, input.base64Image!, ); + + final playlist = await spotify.playlists.get(state.id!); + + ref.read(favoritePlaylistsProvider.notifier).updatePlaylist(playlist); + return playlist; } - return spotify.playlists.get(state.id!); - } catch (e) { + final playlist = Playlist.fromJson( + { + ...state.toJson(), + 'name': input.playlistName, + 'collaborative': input.collaborative, + 'description': input.description, + 'public': input.public, + }, + ); + + ref.read(favoritePlaylistsProvider.notifier).updatePlaylist(playlist); + + return playlist; + } catch (e, stack) { onError?.call(e); + AppLogger.reportError(e, stack); rethrow; } }); - - ref.invalidate(favoritePlaylistsProvider); } } From 7a31119e3ce755e9ed054f48fb508eec3a3bb703 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Jul 2024 22:34:16 +0600 Subject: [PATCH 178/261] fix(mini-player): macos titlebar over logo #1244 --- lib/pages/lyrics/mini_lyrics.dart | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index d9222059..0da512bb 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -81,12 +81,13 @@ class MiniLyricsPage extends HookConsumerWidget { firstChild: DragToMoveArea( child: Row( children: [ - const SizedBox(width: 10), - SizedBox( - height: 30, - width: 30, - child: Sidebar.brandLogo(), - ), + const Gap(10), + if (!kIsMacOS) + SizedBox( + height: 30, + width: 30, + child: Sidebar.brandLogo(), + ), const Spacer(), if (showLyrics.value) SizedBox( From 00f1b3422fa78316381eb954cf344659bda97313 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Jul 2024 22:37:54 +0600 Subject: [PATCH 179/261] fix: playlist share button does not work #1639 --- lib/pages/playlist/playlist.dart | 3 ++- lib/provider/audio_player/audio_player.dart | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/pages/playlist/playlist.dart b/lib/pages/playlist/playlist.dart index 31426f20..e1b33e98 100644 --- a/lib/pages/playlist/playlist.dart +++ b/lib/pages/playlist/playlist.dart @@ -66,7 +66,8 @@ class PlaylistPage extends HookConsumerWidget { tracks: tracks.asData?.value.items ?? [], routePath: '/playlist/${playlist.id}', isLiked: isFavoritePlaylist.asData?.value ?? false, - shareUrl: playlist.externalUrls?.spotify ?? "", + shareUrl: playlist.externalUrls?.spotify ?? + "https://open.spotify.com/playlist/${playlist.id}", onHeart: isFavoritePlaylist.asData?.value == null ? null : () async { diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index 5323f3c0..437b666e 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -287,7 +287,7 @@ class AudioPlayerNotifier extends Notifier { await ref.read(sourcedTrackProvider(intendedActiveTrack).future); } - if(medias.isEmpty) return; + if (medias.isEmpty) return; await removeCollections(state.collections); @@ -317,6 +317,7 @@ class AudioPlayerNotifier extends Notifier { Future stop() async { await audioPlayer.stop(); + await removeCollections(state.collections); ref.read(discordProvider.notifier).clear(); } } From abfdbc63cef8e7d5a7895f8e777dd64d9b73ffe5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Jul 2024 23:08:21 +0600 Subject: [PATCH 180/261] fix: Too many artists for a track causing overflows #1470 --- .../dialogs/track_details_dialog.dart | 1 + lib/components/links/artist_link.dart | 85 ++++++++------ lib/components/track_tile/track_options.dart | 13 ++- lib/components/track_tile/track_tile.dart | 13 ++- lib/l10n/app_en.arb | 3 +- .../library/user_downloads/download_item.dart | 9 ++ lib/modules/player/player.dart | 10 ++ lib/modules/player/player_track_details.dart | 8 ++ lib/modules/stats/common/album_item.dart | 7 ++ lib/modules/stats/common/track_item.dart | 7 ++ lib/pages/connect/control/control.dart | 8 ++ lib/pages/track/track.dart | 7 +- untranslated_messages.json | 106 +++++++++++++++++- 13 files changed, 240 insertions(+), 37 deletions(-) diff --git a/lib/components/dialogs/track_details_dialog.dart b/lib/components/dialogs/track_details_dialog.dart index 2495863c..61bca7b1 100644 --- a/lib/components/dialogs/track_details_dialog.dart +++ b/lib/components/dialogs/track_details_dialog.dart @@ -28,6 +28,7 @@ class TrackDetailsDialog extends HookWidget { artists: track.artists ?? [], mainAxisAlignment: WrapAlignment.start, textStyle: const TextStyle(color: Colors.blue), + hideOverflowArtist: false, ), context.l10n.album: LinkText( track.album!.name!, diff --git a/lib/components/links/artist_link.dart b/lib/components/links/artist_link.dart index 47ddecd8..d5ec24f8 100644 --- a/lib/components/links/artist_link.dart +++ b/lib/components/links/artist_link.dart @@ -1,6 +1,8 @@ +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/links/anchor_button.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -9,7 +11,9 @@ class ArtistLink extends StatelessWidget { final WrapCrossAlignment crossAxisAlignment; final WrapAlignment mainAxisAlignment; final TextStyle textStyle; + final bool hideOverflowArtist; final void Function(String route)? onRouteChange; + final VoidCallback? onOverflowArtistClick; const ArtistLink({ super.key, @@ -18,44 +22,61 @@ class ArtistLink extends StatelessWidget { this.mainAxisAlignment = WrapAlignment.center, this.textStyle = const TextStyle(), this.onRouteChange, - }); + this.hideOverflowArtist = true, + this.onOverflowArtistClick, + }) : assert(hideOverflowArtist ? onOverflowArtistClick != null : true); @override Widget build(BuildContext context) { + final ThemeData(:colorScheme) = Theme.of(context); + return Wrap( crossAxisAlignment: crossAxisAlignment, alignment: mainAxisAlignment, - children: artists - .asMap() - .entries - .map( - (artist) => Builder(builder: (context) { - if (artist.value.name == null) { - return Text("Spotify", style: textStyle); - } - return AnchorButton( - (artist.key != artists.length - 1) - ? "${artist.value.name}, " - : artist.value.name!, - onTap: () { - if (onRouteChange != null) { - onRouteChange?.call("/artist/${artist.value.id}"); - } else { - ServiceUtils.pushNamed( - context, - ArtistPage.name, - pathParameters: { - "id": artist.value.id!, - }, - ); - } - }, - overflow: TextOverflow.ellipsis, - style: textStyle, - ); - }), - ) - .toList(), + children: [ + ...(hideOverflowArtist ? artists.take(3).toList() : artists) + .asMap() + .entries + .map( + (artist) => Builder(builder: (context) { + if (artist.value.name == null) { + return Text("Spotify", style: textStyle); + } + return AnchorButton( + (artist.key != artists.length - 1) + ? "${artist.value.name}, " + : artist.value.name!, + onTap: () { + if (onRouteChange != null) { + onRouteChange?.call("/artist/${artist.value.id}"); + } else { + ServiceUtils.pushNamed( + context, + ArtistPage.name, + pathParameters: { + "id": artist.value.id!, + }, + ); + } + }, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + }), + ), + if (hideOverflowArtist && artists.length > 3) + AnchorButton( + context.l10n.and_n_more(artists.length - 3), + onTap: () { + onOverflowArtistClick?.call(); + }, + overflow: TextOverflow.ellipsis, + style: textStyle.copyWith( + color: colorScheme.secondary, + decoration: TextDecoration.underline, + ), + ), + ], ); } } diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index c6cfdd35..a4c5661a 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -20,6 +20,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; @@ -27,6 +28,7 @@ import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/utils/service_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -313,7 +315,16 @@ class TrackOptions extends HookConsumerWidget { ), subtitle: Align( alignment: Alignment.centerLeft, - child: ArtistLink(artists: track.artists!), + child: ArtistLink( + artists: track.artists!, + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ), + ), ), ), ], diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index 0e8d2cd0..12ce063f 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -17,9 +17,11 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/utils/service_utils.dart'; class TrackTile extends HookConsumerWidget { /// [index] will not be shown if null @@ -245,7 +247,16 @@ class TrackTile extends HookConsumerWidget { : ClipRect( child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 40), - child: ArtistLink(artists: track.artists ?? []), + child: ArtistLink( + artists: track.artists ?? [], + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ), + ), ), ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 04fc8566..ab615225 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -325,5 +325,6 @@ "connect_client_alert": "You're being controlled by {client}", "this_device": "This Device", "remote": "Remote", - "stats": "Stats" + "stats": "Stats", + "and_n_more": "and {count} more" } \ No newline at end of file diff --git a/lib/modules/library/user_downloads/download_item.dart b/lib/modules/library/user_downloads/download_item.dart index bc9abf6a..c4bd7bce 100644 --- a/lib/modules/library/user_downloads/download_item.dart +++ b/lib/modules/library/user_downloads/download_item.dart @@ -7,9 +7,11 @@ import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/services/download_manager/download_status.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; +import 'package:spotube/utils/service_utils.dart'; class DownloadItem extends HookConsumerWidget { final Track track; @@ -62,6 +64,13 @@ class DownloadItem extends HookConsumerWidget { subtitle: ArtistLink( artists: track.artists ?? [], mainAxisAlignment: WrapAlignment.start, + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ), ), trailing: isQueryingSourceInfo ? Text( diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index d75df796..6db84692 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -24,11 +24,13 @@ import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/pages/lyrics/lyrics.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/server/active_sourced_track.dart'; import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/services/sourced_track/sources/youtube.dart'; +import 'package:spotube/utils/service_utils.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -260,6 +262,14 @@ class PlayerView extends HookConsumerWidget { panelController.close(); GoRouter.of(context).push(route); }, + onOverflowArtistClick: () => + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": currentTrack!.id!, + }, + ), ), ], ), diff --git a/lib/modules/player/player_track_details.dart b/lib/modules/player/player_track_details.dart index d722830e..8d3b99fa 100644 --- a/lib/modules/player/player_track_details.dart +++ b/lib/modules/player/player_track_details.dart @@ -9,6 +9,7 @@ import 'package:spotube/components/links/link_text.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -81,6 +82,13 @@ class PlayerTrackDetails extends HookConsumerWidget { onRouteChange: (route) { ServiceUtils.push(context, route); }, + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track!.id!, + }, + ), ) ], ), diff --git a/lib/modules/stats/common/album_item.dart b/lib/modules/stats/common/album_item.dart index 58604c45..eec68717 100644 --- a/lib/modules/stats/common/album_item.dart +++ b/lib/modules/stats/common/album_item.dart @@ -35,6 +35,13 @@ class StatsAlbumItem extends StatelessWidget { child: ArtistLink( artists: album.artists ?? [], mainAxisAlignment: WrapAlignment.start, + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + AlbumPage.name, + pathParameters: { + "id": album.id!, + }, + ), ), ), ], diff --git a/lib/modules/stats/common/track_item.dart b/lib/modules/stats/common/track_item.dart index 33991d43..44e81340 100644 --- a/lib/modules/stats/common/track_item.dart +++ b/lib/modules/stats/common/track_item.dart @@ -33,6 +33,13 @@ class StatsTrackItem extends StatelessWidget { subtitle: ArtistLink( artists: track.artists!, mainAxisAlignment: WrapAlignment.start, + onOverflowArtistClick: () => ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": track.id!, + }, + ), ), trailing: info, onTap: () { diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart index d27b7867..cae0bd1b 100644 --- a/lib/pages/connect/control/control.dart +++ b/lib/pages/connect/control/control.dart @@ -144,6 +144,14 @@ class ConnectControlPage extends HookConsumerWidget { artists: playlist.activeTrack?.artists ?? [], textStyle: textTheme.bodyMedium!, mainAxisAlignment: WrapAlignment.start, + onOverflowArtistClick: () => + ServiceUtils.pushNamed( + context, + TrackPage.name, + pathParameters: { + "id": playlist.activeTrack!.id!, + }, + ), ), ), ], diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index dc4defc8..6f3af0e4 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -148,7 +148,12 @@ class TrackPage extends HookConsumerWidget { children: [ const Icon(SpotubeIcons.artist), const Gap(5), - ArtistLink(artists: track.artists!), + Flexible( + child: ArtistLink( + artists: track.artists!, + hideOverflowArtist: false, + ), + ), ], ), const Gap(10), diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfee..5da4c3c6 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,105 @@ -{} \ No newline at end of file +{ + "ar": [ + "and_n_more" + ], + + "bn": [ + "and_n_more" + ], + + "ca": [ + "and_n_more" + ], + + "cs": [ + "and_n_more" + ], + + "de": [ + "and_n_more" + ], + + "es": [ + "and_n_more" + ], + + "eu": [ + "and_n_more" + ], + + "fa": [ + "and_n_more" + ], + + "fi": [ + "and_n_more" + ], + + "fr": [ + "and_n_more" + ], + + "hi": [ + "and_n_more" + ], + + "id": [ + "and_n_more" + ], + + "it": [ + "and_n_more" + ], + + "ja": [ + "and_n_more" + ], + + "ka": [ + "and_n_more" + ], + + "ko": [ + "and_n_more" + ], + + "ne": [ + "and_n_more" + ], + + "nl": [ + "and_n_more" + ], + + "pl": [ + "and_n_more" + ], + + "pt": [ + "and_n_more" + ], + + "ru": [ + "and_n_more" + ], + + "th": [ + "and_n_more" + ], + + "tr": [ + "and_n_more" + ], + + "uk": [ + "and_n_more" + ], + + "vi": [ + "and_n_more" + ], + + "zh": [ + "and_n_more" + ] +} From d22bba5393f649068a937ae3d2016c6cd5fa89bd Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 10 Jul 2024 00:11:40 +0600 Subject: [PATCH 181/261] fix: incorrect datatype used for MPRIS position property #1521 --- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 14f2a5b0..040e23ec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -85,10 +85,10 @@ packages: dependency: "direct main" description: name: audio_service_mpris - sha256: a8d1583f9143d17b2facc994a99bd1ea257cec43adcb8d7349458555c62b570f + sha256: b16db3584a4b2464c0bfd575c1a21765723d257931222f8adfcb0511f940d352 url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.1.5" audio_service_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 58ca0ae9..06f968f3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ dependencies: args: ^2.5.0 async: ^2.9.0 audio_service: ^0.18.13 - audio_service_mpris: ^0.1.3 + audio_service_mpris: ^0.1.5 audio_session: ^0.1.19 auto_size_text: ^3.0.0 buttons_tabbar: ^1.3.8 From a6e13ffc08691b264bd9fd2e60b53b8762e6f601 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 10 Jul 2024 00:23:22 +0600 Subject: [PATCH 182/261] fix(linux): OS Media control not working for Flatpak #1627 --- lib/services/audio_services/audio_services.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 63e43c4d..6545ab4a 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -22,8 +22,9 @@ class AudioServices { final mobile = kIsMobile || kIsMacOS || kIsLinux ? await AudioService.init( builder: () => MobileAudioService(playback), - config: const AudioServiceConfig( - androidNotificationChannelId: 'com.krtirtho.Spotube', + config: AudioServiceConfig( + androidNotificationChannelId: + kIsLinux ? 'spotube' : 'com.krtirtho.Spotube', androidNotificationChannelName: 'Spotube', androidNotificationOngoing: true, ), From 6a500731d601c3ac7c70699fb3a374c8dff82303 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 14 Jul 2024 18:58:47 +0600 Subject: [PATCH 183/261] feat: discord rpc for macOS, windows-arm64 and linux-arm64 (#1713) * feat: add discord rpc support for macos, windows arm64 and linux arm64 * chore: discord rpc not clearing activity after close/setting rpc to false * chore: add migration script to move from files from macos sandbox to non-sandbox directories --- lib/main.dart | 13 ++- lib/pages/home/home.dart | 6 +- lib/pages/settings/sections/desktop.dart | 15 ++- .../audio_player/audio_player_streams.dart | 2 +- lib/provider/discord_provider.dart | 101 ++++++++++-------- lib/services/logger/logger.dart | 15 +++ lib/utils/migrations/sandbox.dart | 58 ++++++++++ linux/flutter/generated_plugin_registrant.cc | 4 - linux/flutter/generated_plugins.cmake | 2 +- macos/Podfile.lock | 19 ++-- macos/Runner/DebugProfile.entitlements | 30 +++--- macos/Runner/Release.entitlements | 30 +++--- macos/Runner/RunnerDebug.entitlements | 30 +++--- pubspec.lock | 31 +++--- pubspec.yaml | 5 +- .../flutter/generated_plugin_registrant.cc | 3 - windows/flutter/generated_plugins.cmake | 2 +- 17 files changed, 230 insertions(+), 136 deletions(-) create mode 100644 lib/utils/migrations/sandbox.dart diff --git a/lib/main.dart b/lib/main.dart index 7e8da0f2..a9a2e3b9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'package:dart_discord_rpc/dart_discord_rpc.dart'; import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hive/hive.dart'; @@ -12,6 +12,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:local_notifier/local_notifier.dart'; import 'package:media_kit/media_kit.dart'; import 'package:metadata_god/metadata_god.dart'; +import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/initializers.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/intents.dart'; @@ -37,6 +38,7 @@ import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/wm_tools/wm_tools.dart'; import 'package:spotube/themes/theme.dart'; import 'package:spotube/utils/migrations/hive.dart'; +import 'package:spotube/utils/migrations/sandbox.dart'; import 'package:spotube/utils/platform.dart'; import 'package:system_theme/system_theme.dart'; import 'package:path_provider/path_provider.dart'; @@ -67,6 +69,8 @@ Future main(List rawArgs) async { MediaKit.ensureInitialized(); + await migrateMacOsFromSandboxToNoSandbox(); + // force High Refresh Rate on some Android devices (like One Plus) if (kIsAndroid) { await FlutterDisplayMode.setHighRefreshRate(); @@ -82,8 +86,8 @@ Future main(List rawArgs) async { MetadataGod.initialize(); } - if (kIsWindows || kIsLinux) { - DiscordRPC.initialize(); + if (kIsDesktop) { + await FlutterDiscordRPC.initialize(Env.discordAppId); } await KVStoreService.initialize(); @@ -108,6 +112,9 @@ Future main(List rawArgs) async { overrides: [ databaseProvider.overrideWith((ref) => database), ], + observers: const [ + AppLoggerProviderObserver(), + ], child: const Spotube(), ), ); diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index 7afd5938..efdca4f7 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -4,6 +4,7 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/connect/connect_device.dart'; import 'package:spotube/modules/home/sections/featured.dart'; import 'package:spotube/modules/home/sections/feed.dart'; @@ -15,6 +16,7 @@ import 'package:spotube/modules/home/sections/recent.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/pages/settings/settings.dart'; +import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -26,6 +28,8 @@ class HomePage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final controller = useScrollController(); final mediaQuery = MediaQuery.of(context); + final layoutMode = + ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); return SafeArea( bottom: false, @@ -34,7 +38,7 @@ class HomePage extends HookConsumerWidget { body: CustomScrollView( controller: controller, slivers: [ - if (mediaQuery.smAndDown) + if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact) SliverAppBar( floating: true, title: Assets.spotubeLogoPng.image(height: 45), diff --git a/lib/pages/settings/sections/desktop.dart b/lib/pages/settings/sections/desktop.dart index 88f0ae6d..c61f0150 100644 --- a/lib/pages/settings/sections/desktop.dart +++ b/lib/pages/settings/sections/desktop.dart @@ -8,8 +8,6 @@ import 'package:spotube/components/adaptive/adaptive_select_tile.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/utils/platform.dart'; - class SettingsDesktopSection extends HookConsumerWidget { const SettingsDesktopSection({super.key}); @@ -54,13 +52,12 @@ class SettingsDesktopSection extends HookConsumerWidget { value: preferences.systemTitleBar, onChanged: preferencesNotifier.setSystemTitleBar, ), - if (!kIsMacOS) - SwitchListTile( - secondary: const Icon(SpotubeIcons.discord), - title: Text(context.l10n.discord_rich_presence), - value: preferences.discordPresence, - onChanged: preferencesNotifier.setDiscordPresence, - ), + SwitchListTile( + secondary: const Icon(SpotubeIcons.discord), + title: Text(context.l10n.discord_rich_presence), + value: preferences.discordPresence, + onChanged: preferencesNotifier.setDiscordPresence, + ), ], ); } diff --git a/lib/provider/audio_player/audio_player_streams.dart b/lib/provider/audio_player/audio_player_streams.dart index 368fc6d9..845f12ea 100644 --- a/lib/provider/audio_player/audio_player_streams.dart +++ b/lib/provider/audio_player/audio_player_streams.dart @@ -43,7 +43,7 @@ class AudioPlayerStreamListeners { ScrobblerNotifier get scrobbler => ref.read(scrobblerProvider.notifier); UserPreferences get preferences => ref.read(userPreferencesProvider); - Discord get discord => ref.read(discordProvider); + DiscordNotifier get discord => ref.read(discordProvider.notifier); AudioPlayerState get audioPlayerState => ref.read(audioPlayerProvider); PlaybackHistoryActions get history => ref.read(playbackHistoryActionsProvider); diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index 29c53762..1e819af0 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -1,67 +1,76 @@ -import 'package:dart_discord_rpc/dart_discord_rpc.dart'; -import 'package:flutter/foundation.dart'; +import 'dart:async'; + +import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/env.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; -class Discord extends ChangeNotifier { - final DiscordRPC? discordRPC; - final bool isEnabled; +class DiscordNotifier extends AsyncNotifier { + @override + FutureOr build() async { + final enabled = ref.watch( + userPreferencesProvider.select((s) => s.discordPresence && kIsDesktop)); + final playback = ref.read(audioPlayerProvider); - Discord(this.isEnabled) - : discordRPC = (kIsWindows || kIsLinux) && isEnabled - ? DiscordRPC(applicationId: Env.discordAppId) - : null { - discordRPC?.start(autoRegister: true); + final subscription = + FlutterDiscordRPC.instance.isConnectedStream.listen((connected) async { + if (connected && playback.activeTrack != null) { + await updatePresence(playback.activeTrack!); + } + }); + + ref.onDispose(() async { + subscription.cancel(); + await close(); + await FlutterDiscordRPC.instance.dispose(); + }); + + if (!enabled && FlutterDiscordRPC.instance.isConnected) { + await clear(); + await close(); + } else { + await FlutterDiscordRPC.instance.connect(autoRetry: true); + } } - void updatePresence(Track track) { - clear(); + Future updatePresence(Track track) async { + await clear(); final artistNames = track.artists?.asString() ?? ""; - discordRPC?.updatePresence( - DiscordPresence( - details: "Song: ${track.name} by $artistNames", + await FlutterDiscordRPC.instance.setActivity( + activity: RPCActivity( + details: "${track.name} by $artistNames", state: "Vibing in Music", - startTimeStamp: DateTime.now().millisecondsSinceEpoch, - largeImageKey: "spotube-logo-foreground", - largeImageText: "Spotube", - smallImageKey: "spotube-logo-foreground", - smallImageText: "Spotube", + assets: const RPCAssets( + largeImage: "spotube-logo-foreground", + largeText: "Spotube", + smallImage: "spotube-logo-foreground", + smallText: "Spotube", + ), + buttons: [ + RPCButton( + label: "Listen on Spotify", + url: track.externalUrls?.spotify ?? + "https://open.spotify.com/tracks/${track.id}", + ), + ], + timestamps: RPCTimestamps( + start: DateTime.now().millisecondsSinceEpoch, + ), ), ); } - void clear() { - discordRPC?.clearPresence(); + Future clear() async { + await FlutterDiscordRPC.instance.clearActivity(); } - void shutdown() { - discordRPC?.shutDown(); - } - - @override - void dispose() { - clear(); - shutdown(); - super.dispose(); + Future close() async { + await FlutterDiscordRPC.instance.disconnect(); } } -final discordProvider = ChangeNotifierProvider( - (ref) { - final isEnabled = - ref.watch(userPreferencesProvider.select((s) => s.discordPresence)); - final playback = ref.read(audioPlayerProvider); - final discord = Discord(isEnabled); - - if (playback.activeTrack != null) { - discord.updatePresence(playback.activeTrack!); - } - - return discord; - }, -); +final discordProvider = + AsyncNotifierProvider(() => DiscordNotifier()); diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart index 6ba76ea1..38252e87 100644 --- a/lib/services/logger/logger.dart +++ b/lib/services/logger/logger.dart @@ -4,6 +4,7 @@ import 'dart:isolate'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:logger/logger.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; @@ -88,3 +89,17 @@ class AppLogger { } } } + +class AppLoggerProviderObserver extends ProviderObserver { + const AppLoggerProviderObserver(); + + @override + void providerDidFail( + ProviderBase provider, + Object error, + StackTrace stackTrace, + ProviderContainer container, + ) { + AppLogger.reportError(error, stackTrace); + } +} diff --git a/lib/utils/migrations/sandbox.dart b/lib/utils/migrations/sandbox.dart new file mode 100644 index 00000000..1ed5090a --- /dev/null +++ b/lib/utils/migrations/sandbox.dart @@ -0,0 +1,58 @@ +import 'dart:io'; + +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/utils/platform.dart'; + +/// Migrates sandbox files on macOS to non-sandbox directories +Future migrateMacOsFromSandboxToNoSandbox() async { + if (!kIsMacOS) return; + + try { + final sandboxApplicationSupportDir = Directory( + "/Users/${Platform.environment["USER"]}/Library/Containers/oss.krtirtho.spotube/Data/Library/Application Support/oss.krtirtho.spotube", + ); + + if (!await sandboxApplicationSupportDir.exists()) { + stdout.writeln("🔵 Sandbox directory not found, skipping migration"); + return; + } + + const fileExts = [".db", ".lock", ".hive"]; + + final supportDir = await getApplicationSupportDirectory() + ..create(recursive: true); + + final supportFiles = await supportDir.list().toList(); + final oldSupportFiles = await sandboxApplicationSupportDir.list().toList(); + + if (oldSupportFiles.isEmpty) { + stdout.writeln( + "🔵 No files found in sandboxed directory, skipping migration", + ); + return; + } else if (supportFiles.any( + (file) => file is File && fileExts.contains(extension(file.path)))) { + stdout.writeln( + "🔵 Non-sandbox directory is not empty, skipping migration", + ); + return; + } + + for (final oldSupportFile in oldSupportFiles) { + if (oldSupportFile is File && + fileExts.contains(extension(oldSupportFile.path))) { + final newPath = join(supportDir.path, basename(oldSupportFile.path)); + await oldSupportFile.copy(newPath); + } + } + + stdout.writeln("✅ Migrated sandboxed files to non-sandboxed directory"); + } catch (e, stack) { + stdout.writeln( + "❌ Error migrating sandboxed files to non-sandboxed directory", + ); + AppLogger.reportError(e, stack); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 2218d110..5550ed22 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,7 +6,6 @@ #include "generated_plugin_registrant.h" -#include #include #include #include @@ -22,9 +21,6 @@ #include void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) dart_discord_rpc_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "DartDiscordRpcPlugin"); - dart_discord_rpc_plugin_register_with_registrar(dart_discord_rpc_registrar); g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin"); desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index bb0776b5..93bf6bc0 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,7 +3,6 @@ # list(APPEND FLUTTER_PLUGIN_LIST - dart_discord_rpc desktop_webview_window file_selector_linux flutter_secure_storage_linux @@ -20,6 +19,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_discord_rpc media_kit_native_event_loop metadata_god ) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 9ab2ee38..b3bed710 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -14,6 +14,7 @@ PODS: - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS + - flutter_discord_rpc (0.0.1) - flutter_inappwebview_macos (0.0.1): - FlutterMacOS - OrderedSet (~> 5.0) @@ -41,14 +42,14 @@ PODS: - sqflite (0.0.3): - Flutter - FlutterMacOS - - sqlite3 (3.46.0): - - sqlite3/common (= 3.46.0) - - sqlite3/common (3.46.0) - - sqlite3/fts5 (3.46.0): + - "sqlite3 (3.46.0+1)": + - "sqlite3/common (= 3.46.0+1)" + - "sqlite3/common (3.46.0+1)" + - "sqlite3/fts5 (3.46.0+1)": - sqlite3/common - - sqlite3/perf-threadsafe (3.46.0): + - "sqlite3/perf-threadsafe (3.46.0+1)": - sqlite3/common - - sqlite3/rtree (3.46.0): + - "sqlite3/rtree (3.46.0+1)": - sqlite3/common - sqlite3_flutter_libs (0.0.1): - FlutterMacOS @@ -75,6 +76,7 @@ DEPENDENCIES: - desktop_webview_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - flutter_discord_rpc (from `Flutter/ephemeral/.symlinks/plugins/flutter_discord_rpc/macos`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) @@ -114,6 +116,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_selector_macos: :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + flutter_discord_rpc: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_discord_rpc/macos flutter_inappwebview_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_secure_storage_macos: @@ -159,6 +163,7 @@ SPEC CHECKSUMS: desktop_webview_window: 89bb3d691f4c80314a10be312f4cd35db93a9d5a device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 + flutter_discord_rpc: 53b006f68ef620a99fe1b3ba7e83513f3ae95b4c flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 @@ -172,7 +177,7 @@ SPEC CHECKSUMS: screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec - sqlite3: 154b084339ede06960a5b3c8160066adc9176b7d + sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 sqlite3_flutter_libs: 1be4459672f8168ded2d8667599b8e3ca5e72b83 system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc system_tray: e53c972838c69589ff2e77d6d3abfd71332f9e5d diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index f05277de..6e73fa3c 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -1,18 +1,18 @@ - - com.apple.security.app-sandbox - - com.apple.security.assets.music.read-write - - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - - + + com.apple.security.app-sandbox + + com.apple.security.assets.music.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + + \ No newline at end of file diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index f05277de..6e73fa3c 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -1,18 +1,18 @@ - - com.apple.security.app-sandbox - - com.apple.security.assets.music.read-write - - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - - + + com.apple.security.app-sandbox + + com.apple.security.assets.music.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + + \ No newline at end of file diff --git a/macos/Runner/RunnerDebug.entitlements b/macos/Runner/RunnerDebug.entitlements index f05277de..6e73fa3c 100644 --- a/macos/Runner/RunnerDebug.entitlements +++ b/macos/Runner/RunnerDebug.entitlements @@ -1,18 +1,18 @@ - - com.apple.security.app-sandbox - - com.apple.security.assets.music.read-write - - com.apple.security.files.downloads.read-write - - com.apple.security.files.user-selected.read-write - - com.apple.security.network.client - - com.apple.security.network.server - - - + + com.apple.security.app-sandbox + + com.apple.security.assets.music.read-write + + com.apple.security.files.downloads.read-write + + com.apple.security.files.user-selected.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + + \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 040e23ec..28b682a8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -221,10 +221,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "644dc98a0f179b872f612d3eb627924b578897c629788e858157fa5e704ca0c7" + sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" url: "https://pub.dev" source: hosted - version: "2.4.11" + version: "2.4.9" build_runner_core: dependency: transitive description: @@ -433,15 +433,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" - dart_discord_rpc: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "4d05017838ebeadcdb832e1893fabad1506fddba" - url: "https://github.com/Tommypop2/dart_discord_rpc.git" - source: git - version: "0.0.3" dart_mappable: dependency: transitive description: @@ -721,6 +712,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.1" + flutter_discord_rpc: + dependency: "direct main" + description: + name: flutter_discord_rpc + sha256: "290c0d91c8ef24c3acb84cb6fcc5c5fed652fe4871b59896c8d180d5eae5d647" + url: "https://pub.dev" + source: hosted + version: "0.1.0+1" flutter_displaymode: dependency: "direct main" description: @@ -1791,6 +1790,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + retry: + dependency: "direct main" + description: + name: retry + sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" + url: "https://pub.dev" + source: hosted + version: "3.1.2" riverpod: dependency: "direct main" description: @@ -2488,5 +2495,5 @@ packages: source: hosted version: "2.2.1" sdks: - dart: ">=3.4.0 <4.0.0" + dart: ">=3.3.0 <4.0.0" flutter: ">=3.19.2" diff --git a/pubspec.yaml b/pubspec.yaml index 06f968f3..58c8c55f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -100,9 +100,7 @@ dependencies: very_good_infinite_list: ^0.7.1 gap: ^3.0.1 sliver_tools: ^0.2.12 - dart_discord_rpc: - git: - url: https://github.com/Tommypop2/dart_discord_rpc.git + flutter_discord_rpc: ^0.1.0+1 html_unescape: ^2.0.0 wikipedia_api: ^0.1.0 skeletonizer: ^1.1.1 @@ -130,6 +128,7 @@ dependencies: sqlite3_flutter_libs: ^0.5.23 sqlite3: ^2.4.3 encrypt: ^5.0.3 + retry: ^3.1.2 dev_dependencies: build_runner: ^2.4.9 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 4fcf3019..217a7cb4 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,7 +8,6 @@ #include #include -#include #include #include #include @@ -28,8 +27,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("AppLinksPluginCApi")); BonsoirWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi")); - DartDiscordRpcPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("DartDiscordRpcPlugin")); DesktopWebviewWindowPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); FileSelectorWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index d0dd6751..cbbd2acc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,7 +5,6 @@ list(APPEND FLUTTER_PLUGIN_LIST app_links bonsoir_windows - dart_discord_rpc desktop_webview_window file_selector_windows flutter_secure_storage_windows @@ -22,6 +21,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_discord_rpc media_kit_native_event_loop metadata_god smtc_windows From e6fee03c2066a2e1b8f6f5b97c688ac40439a003 Mon Sep 17 00:00:00 2001 From: arenekosreal <17194552+arenekosreal@users.noreply.github.com> Date: Sun, 14 Jul 2024 21:38:28 +0800 Subject: [PATCH 184/261] feat(linux): Use XDG_STATE_HOME to storage logs (#1675) * feat(linux): Use XDG_STATE_HOME to storage logs * fix: Clean LSP suggestions. * fix: Use Platform.environment instead String.fromEnvironment The latter seems return an empty string. See: https://github.com/flutter/flutter/issues/55870#issuecomment-936612420 --- lib/services/logger/logger.dart | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart index 38252e87..1df7b5aa 100644 --- a/lib/services/logger/logger.dart +++ b/lib/services/logger/logger.dart @@ -65,6 +65,11 @@ class AppLogger { if (kIsMacOS) { dir = join((await getLibraryDirectory()).path, "Logs"); } + + if (kIsLinux) { + dir = join(_getXdgStateHome(), "spotube"); + } + final file = File(join(dir, ".spotube_logs")); if (!await file.exists()) { await file.create(recursive: true); @@ -88,6 +93,20 @@ class AppLogger { ); } } + + static String _getXdgStateHome() { + // path_provider seems does not support XDG_STATE_HOME, + // which is the specification to store application logs on Linux. + // See https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + // TODO: Use path_provider once it supports XDG_STATE_HOME + if (const bool.hasEnvironment("XDG_STATE_HOME")) { + String xdgStateHomeRaw = Platform.environment["XDG_STATE_HOME"] ?? ""; + if (xdgStateHomeRaw.isNotEmpty) { + return xdgStateHomeRaw; + } + } + return join(Platform.environment["HOME"] ?? "", ".local", "state"); + } } class AppLoggerProviderObserver extends ProviderObserver { From a2ba46ea45471e42ce28ed544f825ab3f0cf4b0a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 14 Jul 2024 21:24:35 +0600 Subject: [PATCH 185/261] fix(android): app getting killed from background --- android/app/build.gradle | 2 +- lib/provider/history/recent.dart | 12 ++++---- .../audio_services/audio_services.dart | 28 +++++++++++++++---- pubspec.lock | 28 +++++++++---------- 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 7bcd9b6a..e175f356 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -31,7 +31,7 @@ if (keystorePropertiesFile.exists()) { android { compileSdkVersion 34 - ndkVersion "21.4.7075529" + ndkVersion "25.1.8937393" compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/lib/provider/history/recent.dart b/lib/provider/history/recent.dart index 8894b713..ef393a17 100644 --- a/lib/provider/history/recent.dart +++ b/lib/provider/history/recent.dart @@ -9,13 +9,15 @@ class RecentlyPlayedItemNotifier extends AsyncNotifier> { build() async { final database = ref.watch(databaseProvider); - final uniqueItemIds = await (database.selectOnly(database.historyTable, - distinct: true) + final uniqueItemIds = await (database.selectOnly( + database.historyTable, + distinct: true, + ) ..addColumns([database.historyTable.itemId, database.historyTable.id]) ..where( - database.historyTable.type.isIn([ - HistoryEntryType.playlist.name, - HistoryEntryType.album.name, + database.historyTable.type.isInValues([ + HistoryEntryType.playlist, + HistoryEntryType.album, ]), ) ..limit(10) diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 6545ab4a..dbddba8b 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -1,4 +1,5 @@ import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/artist_simple.dart'; @@ -9,11 +10,13 @@ import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; import 'package:spotube/utils/platform.dart'; -class AudioServices { +class AudioServices with WidgetsBindingObserver { final MobileAudioService? mobile; final WindowsAudioService? smtc; - AudioServices(this.mobile, this.smtc); + AudioServices(this.mobile, this.smtc) { + WidgetsBinding.instance.addObserver(this); + } static Future create( Ref ref, @@ -27,15 +30,15 @@ class AudioServices { kIsLinux ? 'spotube' : 'com.krtirtho.Spotube', androidNotificationChannelName: 'Spotube', androidNotificationOngoing: true, + androidNotificationIcon: "drawable/ic_launcher_monochrome", + androidStopForegroundOnPause: false, + androidNotificationChannelDescription: "Spotube Media Controls", ), ) : null; final smtc = kIsWindows ? WindowsAudioService(ref, playback) : null; - return AudioServices( - mobile, - smtc, - ); + return AudioServices(mobile, smtc); } Future addTrack(Track track) async { @@ -65,7 +68,20 @@ class AudioServices { mobile?.session?.setActive(false); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.detached: + deactivateSession(); + mobile?.stop(); + break; + default: + break; + } + } + void dispose() { smtc?.dispose(); + WidgetsBinding.instance.removeObserver(this); } } diff --git a/pubspec.lock b/pubspec.lock index 28b682a8..716f5d22 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1266,10 +1266,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.18.1" introduction_screen: dependency: "direct main" description: @@ -1322,26 +1322,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.0" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "2.0.1" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "2.0.1" lints: dependency: transitive description: @@ -1474,10 +1474,10 @@ packages: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.11.0" metadata_god: dependency: "direct main" description: @@ -2186,10 +2186,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.6.1" time: dependency: transitive description: @@ -2394,10 +2394,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "13.0.0" watcher: dependency: transitive description: From 1441736627bb826fb2d0e130f909037d1588e8a3 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 26 Jul 2024 15:31:58 +0600 Subject: [PATCH 186/261] fix(windows): window stretching #1553 --- .../use_fix_window_stretching.dart | 21 +++++++++++++++++++ lib/main.dart | 2 ++ windows/runner/main.cpp | 5 ++--- 3 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 lib/hooks/configurators/use_fix_window_stretching.dart diff --git a/lib/hooks/configurators/use_fix_window_stretching.dart b/lib/hooks/configurators/use_fix_window_stretching.dart new file mode 100644 index 00000000..a6603d59 --- /dev/null +++ b/lib/hooks/configurators/use_fix_window_stretching.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/utils/platform.dart'; +import 'package:window_manager/window_manager.dart'; + +void useFixWindowStretching() { + useEffect(() { + if (!kIsWindows) return; + WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) async { + await Future.delayed(const Duration(milliseconds: 100), () { + windowManager.getSize().then((Size value) { + windowManager.setSize( + Size(value.width + 1, value.height + 1), + ); + }); + }); + }); + + return null; + }, []); +} diff --git a/lib/main.dart b/lib/main.dart index 45f4462d..f4292bd6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,6 +18,7 @@ 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_fix_window_stretching.dart'; import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/audio_player/audio_player_streams.dart'; @@ -134,6 +135,7 @@ class Spotube extends HookConsumerWidget { ref.listen(serverProvider, (_, __) {}); ref.listen(trayManagerProvider, (_, __) {}); + useFixWindowStretching(); useDisableBatteryOptimizations(); useDeepLinking(ref); useCloseBehavior(ref); diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp index b938ff49..d86a2421 100644 --- a/windows/runner/main.cpp +++ b/windows/runner/main.cpp @@ -19,14 +19,13 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, flutter::DartProject project(L"data"); - std::vector command_line_arguments = - GetCommandLineArguments(); + std::vector command_line_arguments = GetCommandLineArguments(); project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); FlutterWindow window(project); Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); + Win32Window::Size size(1200, 800); if (!window.CreateAndShow(L"spotube", origin, size)) { return EXIT_FAILURE; } From 9b05b8adf1641e45e838d01fcbc2c74659d225a0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 26 Jul 2024 15:32:49 +0600 Subject: [PATCH 187/261] chore: migrate to flutter 3.22.3 --- .fvm/fvm_config.json | 2 +- .github/workflows/spotube-release-binary.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index df8efa0e..160b5b29 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "3.22.1", + "flutterSdkVersion": "3.22.3", "flavors": {} } \ No newline at end of file diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index e99aebab..a67e9c13 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -20,7 +20,7 @@ on: description: Dry run without uploading to release env: - FLUTTER_VERSION: 3.19.6 + FLUTTER_VERSION: 3.22.3 permissions: contents: write From bd511584e712d03b2dd862d9657ef95f1357bf55 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 22 Jul 2024 09:46:04 +0600 Subject: [PATCH 188/261] fix: local track metadata timeout --- lib/provider/local_tracks/local_tracks_provider.dart | 8 +++++--- lib/services/audio_services/audio_services.dart | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart index 6d2da59c..c739722b 100644 --- a/lib/provider/local_tracks/local_tracks_provider.dart +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:spotube/services/logger/logger.dart'; @@ -49,7 +50,7 @@ final localTracksProvider = userPreferencesProvider.select((s) => s.localLibraryLocation), ); - for (var location in [downloadLocation, ...localLibraryLocations]) { + for (final location in [downloadLocation, ...localLibraryLocations]) { if (location.isEmpty) continue; final entities = []; if (await Directory(location).exists()) { @@ -67,7 +68,8 @@ final localTracksProvider = }).map( (file) async { try { - final metadata = await MetadataGod.readMetadata(file: file.path); + final metadata = await MetadataGod.readMetadata(file: file.path) + .timeout(const Duration(seconds: 10)); final imageFile = File(join( (await getTemporaryDirectory()).path, @@ -89,7 +91,7 @@ final localTracksProvider = "art": imageFile.path }; } catch (e, stack) { - if (e is FfiException) { + if (e case FfiException() || TimeoutException()) { return {"file": file}; } AppLogger.reportError(e, stack); diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index dbddba8b..d1820a00 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -29,7 +29,7 @@ class AudioServices with WidgetsBindingObserver { androidNotificationChannelId: kIsLinux ? 'spotube' : 'com.krtirtho.Spotube', androidNotificationChannelName: 'Spotube', - androidNotificationOngoing: true, + androidNotificationOngoing: false, androidNotificationIcon: "drawable/ic_launcher_monochrome", androidStopForegroundOnPause: false, androidNotificationChannelDescription: "Spotube Media Controls", From b211813213b11c2c95df454b383886994636fbc8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 22 Jul 2024 09:54:59 +0600 Subject: [PATCH 189/261] fix: go to track album shows up for local tracks --- lib/components/track_tile/track_options.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index a4c5661a..84b0f41f 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -335,7 +335,7 @@ class TrackOptions extends HookConsumerWidget { leading: const Icon(SpotubeIcons.trash), title: Text(context.l10n.delete), ), - if (mediaQuery.smAndDown) + if (mediaQuery.smAndDown && !isLocalTrack) PopSheetEntry( value: TrackOptionValue.album, leading: const Icon(SpotubeIcons.album), From 0eb78d14ca4da02d29e180d13569b950462ab088 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 1 Aug 2024 14:15:40 +0600 Subject: [PATCH 190/261] chore: use frb based plugins from git --- .github/workflows/spotube-release-binary.yml | 4 + lib/main.dart | 5 + lib/provider/download_manager_provider.dart | 2 +- .../local_tracks/local_tracks_provider.dart | 96 +++--- .../audio_services/smtc_windows_web.dart | 276 ------------------ .../audio_services/windows_audio_service.dart | 10 +- macos/Podfile.lock | 10 +- pubspec.lock | 92 ++---- pubspec.yaml | 23 +- 9 files changed, 118 insertions(+), 400 deletions(-) delete mode 100644 lib/services/audio_services/smtc_windows_web.dart diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index a67e9c13..8fcf228a 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -82,6 +82,10 @@ jobs: - name: Set up Docker Buildx if: ${{matrix.platform == 'linux_arm'}} uses: docker/setup-buildx-action@v3 + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable - name: Install ${{matrix.platform}} dependencies run: | diff --git a/lib/main.dart b/lib/main.dart index d1f19d7a..64710f47 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -12,6 +12,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:local_notifier/local_notifier.dart'; import 'package:media_kit/media_kit.dart'; import 'package:metadata_god/metadata_god.dart'; +import 'package:smtc_windows/smtc_windows.dart'; import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/initializers.dart'; import 'package:spotube/collections/routes.dart'; @@ -91,6 +92,10 @@ Future main(List rawArgs) async { await FlutterDiscordRPC.initialize(Env.discordAppId); } + if(kIsWindows){ + await SMTCWindows.initialize(); + } + await KVStoreService.initialize(); await EncryptedKvStoreService.initialize(); diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 0e80d729..ec6ffc18 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -70,7 +70,7 @@ class DownloadManagerProvider extends ChangeNotifier { trackNumber: track.trackNumber, discNumber: track.discNumber, durationMs: track.durationMs?.toDouble() ?? 0.0, - fileSize: await file.length(), + fileSize: BigInt.from(await file.length()), trackTotal: track.album?.tracks?.length ?? 0, picture: imageBytes != null ? Picture( diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart index c739722b..ca22d841 100644 --- a/lib/provider/local_tracks/local_tracks_provider.dart +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -14,7 +14,7 @@ import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; // ignore: depend_on_referenced_packages -import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException; +import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FrbException; const supportedAudioTypes = [ "audio/webm", @@ -37,7 +37,7 @@ final localTracksProvider = FutureProvider>>((ref) async { try { if (kIsWeb) return {}; - final Map> tracks = {}; + final Map> libraryToTracks = {}; final downloadLocation = ref.watch( userPreferencesProvider.select((s) => s.downloadLocation), @@ -52,59 +52,61 @@ final localTracksProvider = for (final location in [downloadLocation, ...localLibraryLocations]) { if (location.isEmpty) continue; - final entities = []; + final entities = []; if (await Directory(location).exists()) { try { - entities.addAll(Directory(location).listSync(recursive: true)); + final dirEntities = + await Directory(location).list(recursive: true).toList(); + + entities.addAll( + dirEntities + .where( + (e) => + e is File && + supportedAudioTypes.contains(lookupMimeType(e.path)), + ) + .cast(), + ); } catch (e, stack) { AppLogger.reportError(e, stack); } } - final filesWithMetadata = (await Future.wait( - entities.map((e) => File(e.path)).where((file) { - final mimetype = lookupMimeType(file.path); - return mimetype != null && supportedAudioTypes.contains(mimetype); - }).map( - (file) async { - try { - final metadata = await MetadataGod.readMetadata(file: file.path) - .timeout(const Duration(seconds: 10)); + final List> filesWithMetadata = []; - final imageFile = File(join( - (await getTemporaryDirectory()).path, - "spotube", - basenameWithoutExtension(file.path) + - imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, - )); - if (!await imageFile.exists() && metadata.picture != null) { - await imageFile.create(recursive: true); - await imageFile.writeAsBytes( - metadata.picture?.data ?? [], - mode: FileMode.writeOnly, - ); - } + for (final file in entities) { + try { + final metadata = await MetadataGod.readMetadata(file: file.path); - return { - "metadata": metadata, - "file": file, - "art": imageFile.path - }; - } catch (e, stack) { - if (e case FfiException() || TimeoutException()) { - return {"file": file}; - } - AppLogger.reportError(e, stack); - return {}; - } - }, - ), - )) - .where((e) => e.isNotEmpty) - .toList(); + await Future.delayed(const Duration(milliseconds: 50)); - // ignore: no_leading_underscores_for_local_identifiers - final _tracks = filesWithMetadata + final imageFile = File(join( + (await getTemporaryDirectory()).path, + "spotube", + basenameWithoutExtension(file.path) + + imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, + )); + if (!await imageFile.exists() && metadata.picture != null) { + await imageFile.create(recursive: true); + await imageFile.writeAsBytes( + metadata.picture?.data ?? [], + mode: FileMode.writeOnly, + ); + } + + filesWithMetadata.add( + {"metadata": metadata, "file": file, "art": imageFile.path}, + ); + } catch (e, stack) { + if (e case FrbException() || TimeoutException()) { + filesWithMetadata.add({"file": file}); + } + AppLogger.reportError(e, stack); + continue; + } + } + + final tracksFromMetadata = filesWithMetadata .map( (fileWithMetadata) => LocalTrack.fromTrack( track: Track().fromFile( @@ -117,9 +119,9 @@ final localTracksProvider = ) .toList(); - tracks[location] = _tracks; + libraryToTracks[location] = tracksFromMetadata; } - return tracks; + return libraryToTracks; } catch (e, stack) { AppLogger.reportError(e, stack); return {}; diff --git a/lib/services/audio_services/smtc_windows_web.dart b/lib/services/audio_services/smtc_windows_web.dart deleted file mode 100644 index 055d43be..00000000 --- a/lib/services/audio_services/smtc_windows_web.dart +++ /dev/null @@ -1,276 +0,0 @@ -// ignore_for_file: constant_identifier_names - -class MusicMetadata { - final String? title; - final String? artist; - final String? album; - final String? albumArtist; - final String? thumbnail; - - const MusicMetadata({ - this.title, - this.artist, - this.album, - this.albumArtist, - this.thumbnail, - }); -} - -enum PlaybackStatus { - Closed, - Changing, - Stopped, - Playing, - Paused, -} - -enum PressedButton { - play, - pause, - next, - previous, - fastForward, - rewind, - stop, - record, - channelUp, - channelDown; - - static PressedButton fromString(String button) { - switch (button) { - case 'play': - return PressedButton.play; - case 'pause': - return PressedButton.pause; - case 'next': - return PressedButton.next; - case 'previous': - return PressedButton.previous; - case 'fast_forward': - return PressedButton.fastForward; - case 'rewind': - return PressedButton.rewind; - case 'stop': - return PressedButton.stop; - case 'record': - return PressedButton.record; - case 'channel_up': - return PressedButton.channelUp; - case 'channel_down': - return PressedButton.channelDown; - default: - throw Exception('Unknown button: $button'); - } - } -} - -class SMTCConfig { - final bool playEnabled; - final bool pauseEnabled; - final bool stopEnabled; - final bool nextEnabled; - final bool prevEnabled; - final bool fastForwardEnabled; - final bool rewindEnabled; - - const SMTCConfig({ - required this.playEnabled, - required this.pauseEnabled, - required this.stopEnabled, - required this.nextEnabled, - required this.prevEnabled, - required this.fastForwardEnabled, - required this.rewindEnabled, - }); -} - -enum RepeatMode { - none, - track, - list; - - static RepeatMode fromString(String value) { - switch (value) { - case 'none': - return none; - case 'track': - return track; - case 'list': - return list; - default: - throw Exception('Unknown repeat mode: $value'); - } - } - - String get asString => toString().split('.').last; -} - -class PlaybackTimeline { - final int startTimeMs; - final int endTimeMs; - final int positionMs; - final int? minSeekTimeMs; - final int? maxSeekTimeMs; - - const PlaybackTimeline({ - required this.startTimeMs, - required this.endTimeMs, - required this.positionMs, - this.minSeekTimeMs, - this.maxSeekTimeMs, - }); -} - -class SMTCWindows { - SMTCWindows({ - SMTCConfig? config, - PlaybackTimeline? timeline, - MusicMetadata? metadata, - PlaybackStatus? status, - bool? shuffleEnabled, - RepeatMode? repeatMode, - bool? enabled, - }); - - SMTCConfig get config => throw UnimplementedError(); - PlaybackTimeline get timeline => throw UnimplementedError(); - MusicMetadata get metadata => throw UnimplementedError(); - PlaybackStatus? get status => throw UnimplementedError(); - Stream get buttonPressStream => throw UnimplementedError(); - Stream get shuffleChangeStream => throw UnimplementedError(); - Stream get repeatModeChangeStream => throw UnimplementedError(); - - bool get isPlayEnabled => config.playEnabled; - bool get isPauseEnabled => config.pauseEnabled; - bool get isStopEnabled => config.stopEnabled; - bool get isNextEnabled => config.nextEnabled; - bool get isPrevEnabled => config.prevEnabled; - bool get isFastForwardEnabled => config.fastForwardEnabled; - bool get isRewindEnabled => config.rewindEnabled; - - bool get isShuffleEnabled => throw UnimplementedError(); - RepeatMode get repeatMode => throw UnimplementedError(); - bool get enabled => throw UnimplementedError(); - - Duration? get startTime => Duration(milliseconds: timeline.startTimeMs); - Duration? get endTime => Duration(milliseconds: timeline.endTimeMs); - Duration? get position => Duration(milliseconds: timeline.positionMs); - Duration? get minSeekTime => timeline.minSeekTimeMs == null - ? null - : Duration(milliseconds: timeline.minSeekTimeMs!); - Duration? get maxSeekTime => timeline.maxSeekTimeMs == null - ? null - : Duration(milliseconds: timeline.maxSeekTimeMs!); - - Future updateConfig(SMTCConfig config) { - throw UnimplementedError(); - } - - Future updateTimeline(PlaybackTimeline timeline) { - throw UnimplementedError(); - } - - Future updateMetadata(MusicMetadata metadata) { - throw UnimplementedError(); - } - - Future clearMetadata() { - throw UnimplementedError(); - } - - Future dispose() async { - throw UnimplementedError(); - } - - Future disableSmtc() { - throw UnimplementedError(); - } - - Future enableSmtc() { - throw UnimplementedError(); - } - - Future setPlaybackStatus(PlaybackStatus status) async { - throw UnimplementedError(); - } - - Future setIsPlayEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsPauseEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsStopEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsNextEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsPrevEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsFastForwardEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setIsRewindEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setTimeline(PlaybackTimeline timeline) { - return updateTimeline(timeline); - } - - Future setTitle(String title) { - throw UnimplementedError(); - } - - Future setArtist(String artist) { - throw UnimplementedError(); - } - - Future setAlbum(String album) { - throw UnimplementedError(); - } - - Future setAlbumArtist(String albumArtist) { - throw UnimplementedError(); - } - - Future setThumbnail(String thumbnail) { - throw UnimplementedError(); - } - - Future setPosition(Duration position) { - throw UnimplementedError(); - } - - Future setStartTime(Duration startTime) { - throw UnimplementedError(); - } - - Future setEndTime(Duration endTime) { - throw UnimplementedError(); - } - - Future setMaxSeekTime(Duration maxSeekTime) { - throw UnimplementedError(); - } - - Future setMinSeekTime(Duration minSeekTime) { - throw UnimplementedError(); - } - - Future setShuffleEnabled(bool enabled) { - throw UnimplementedError(); - } - - Future setRepeatMode(RepeatMode repeatMode) { - throw UnimplementedError(); - } -} diff --git a/lib/services/audio_services/windows_audio_service.dart b/lib/services/audio_services/windows_audio_service.dart index 0b3113fc..8edc5069 100644 --- a/lib/services/audio_services/windows_audio_service.dart +++ b/lib/services/audio_services/windows_audio_service.dart @@ -18,7 +18,7 @@ class WindowsAudioService { WindowsAudioService(this.ref, this.audioPlayerNotifier) : smtc = SMTCWindows(enabled: false) { - smtc.setPlaybackStatus(PlaybackStatus.Stopped); + smtc.setPlaybackStatus(PlaybackStatus.stopped); final buttonStream = smtc.buttonPressStream.listen((event) { switch (event) { case PressedButton.play: @@ -45,16 +45,16 @@ class WindowsAudioService { audioPlayer.playerStateStream.listen((state) async { switch (state) { case AudioPlaybackState.playing: - await smtc.setPlaybackStatus(PlaybackStatus.Playing); + await smtc.setPlaybackStatus(PlaybackStatus.playing); break; case AudioPlaybackState.paused: - await smtc.setPlaybackStatus(PlaybackStatus.Paused); + await smtc.setPlaybackStatus(PlaybackStatus.paused); break; case AudioPlaybackState.stopped: - await smtc.setPlaybackStatus(PlaybackStatus.Stopped); + await smtc.setPlaybackStatus(PlaybackStatus.stopped); break; case AudioPlaybackState.completed: - await smtc.setPlaybackStatus(PlaybackStatus.Changing); + await smtc.setPlaybackStatus(PlaybackStatus.changing); break; default: break; diff --git a/macos/Podfile.lock b/macos/Podfile.lock index b3bed710..9b122cbe 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -14,7 +14,8 @@ PODS: - FlutterMacOS - file_selector_macos (0.0.1): - FlutterMacOS - - flutter_discord_rpc (0.0.1) + - flutter_discord_rpc (0.0.1): + - FlutterMacOS - flutter_inappwebview_macos (0.0.1): - FlutterMacOS - OrderedSet (~> 5.0) @@ -27,7 +28,8 @@ PODS: - FlutterMacOS - media_kit_native_event_loop (1.0.0): - FlutterMacOS - - metadata_god (0.0.1) + - metadata_god (0.0.1): + - FlutterMacOS - OrderedSet (5.0.0) - package_info_plus (0.0.1): - FlutterMacOS @@ -163,14 +165,14 @@ SPEC CHECKSUMS: desktop_webview_window: 89bb3d691f4c80314a10be312f4cd35db93a9d5a device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 - flutter_discord_rpc: 53b006f68ef620a99fe1b3ba7e83513f3ae95b4c + flutter_discord_rpc: 67a7c10ea24d9d3bf35d01af643f48fbcfa7c24f flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 - metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 + metadata_god: 829f61208b44ac1173e7cd32ab740d8776be5435 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c diff --git a/pubspec.lock b/pubspec.lock index 716f5d22..b21c0091 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -715,10 +715,9 @@ packages: flutter_discord_rpc: dependency: "direct main" description: - name: flutter_discord_rpc - sha256: "290c0d91c8ef24c3acb84cb6fcc5c5fed652fe4871b59896c8d180d5eae5d647" - url: "https://pub.dev" - source: hosted + path: "../frb_plugins/packages/flutter_discord_rpc" + relative: true + source: path version: "0.1.0+1" flutter_displaymode: dependency: "direct main" @@ -918,10 +917,10 @@ packages: dependency: transitive description: name: flutter_rust_bridge - sha256: "02720226035257ad0b571c1256f43df3e1556a499f6bcb004849a0faaa0e87f0" + sha256: fac14d2dd67eeba29a20e5d99fac0d4d9fcd552cdf6bf4f8945f7679c6b07b1d url: "https://pub.dev" source: hosted - version: "1.82.6" + version: "2.1.0" flutter_secure_storage: dependency: "direct main" description: @@ -1266,10 +1265,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3bc132a9dbce73a7e4a21a17d06e1878839ffbf975568bc875c60537824b0c4d" + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf url: "https://pub.dev" source: hosted - version: "0.18.1" + version: "0.19.0" introduction_screen: dependency: "direct main" description: @@ -1322,26 +1321,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -1474,18 +1473,17 @@ packages: dependency: transitive description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" metadata_god: dependency: "direct main" description: - name: metadata_god - sha256: cf13931c39eba0b9443d16e8940afdabee125bf08945f18d4c0d02bcae2a3317 - url: "https://pub.dev" - source: hosted - version: "0.5.2+1" + path: "../frb_plugins/packages/metadata_god" + relative: true + source: path + version: "0.5.3" mime: dependency: "direct main" description: @@ -1766,14 +1764,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.3" - puppeteer: - dependency: transitive - description: - name: puppeteer - sha256: "6833edca01b1e9dcdd9a6e41bad84b706dfba4366d095c4edff64b00c02ac472" - url: "https://pub.dev" - source: hosted - version: "3.8.0" quiver: dependency: transitive description: @@ -1935,14 +1925,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.4" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e - url: "https://pub.dev" - source: hosted - version: "1.1.2" shelf_web_socket: dependency: "direct main" description: @@ -1999,10 +1981,9 @@ packages: smtc_windows: dependency: "direct main" description: - name: smtc_windows - sha256: "0fd64d0c6a0c8ea4ea7908d31195eadc8f6d45d5245159fc67259e9e8704100f" - url: "https://pub.dev" - source: hosted + path: "../frb_plugins/packages/smtc_windows" + relative: true + source: path version: "0.1.3" source_gen: dependency: transitive @@ -2031,12 +2012,11 @@ packages: spotify: dependency: "direct main" description: - path: "." - ref: "fix/explicit-to-json" - resolved-ref: c4b37c599413ac7bfd78993e416a56105c62b634 - url: "https://github.com/KRTirtho/spotify-dart.git" - source: git - version: "0.13.6" + name: spotify + sha256: "705f09a457a893973451c15f4072670ac4783d67e42c35c080c55a48dee3a01f" + url: "https://pub.dev" + source: hosted + version: "0.13.7" sprintf: dependency: transitive description: @@ -2186,10 +2166,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" time: dependency: transitive description: @@ -2230,14 +2210,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" - tuple: - dependency: transitive - description: - name: tuple - sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 - url: "https://pub.dev" - source: hosted - version: "2.0.2" type_plus: dependency: transitive description: @@ -2394,10 +2366,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" watcher: dependency: transitive description: @@ -2495,5 +2467,5 @@ packages: source: hosted version: "2.2.1" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.4.0 <4.0.0" flutter: ">=3.19.2" diff --git a/pubspec.yaml b/pubspec.yaml index 58c8c55f..983d4001 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,7 +65,11 @@ dependencies: logger: ^2.0.2 media_kit: ^1.1.10+1 media_kit_libs_audio: ^1.0.4 - metadata_god: ^0.5.2+1 + metadata_god: + git: + url: https://github.com/KRTirtho/frb_plugins.git + path: packages/metadata_god + ref: cargokit mime: ^1.0.2 package_info_plus: ^6.0.0 palette_generator: ^0.3.3 @@ -81,7 +85,11 @@ dependencies: scroll_to_index: ^3.0.1 sidebarx: ^0.17.1 shared_preferences: ^2.2.3 - smtc_windows: ^0.1.3 + smtc_windows: + git: + url: https://github.com/KRTirtho/frb_plugins.git + path: packages/smtc_windows + ref: cargokit stroke_text: ^0.0.2 system_theme: ^2.1.0 titlebar_buttons: ^1.0.0 @@ -100,7 +108,11 @@ dependencies: very_good_infinite_list: ^0.7.1 gap: ^3.0.1 sliver_tools: ^0.2.12 - flutter_discord_rpc: ^0.1.0+1 + flutter_discord_rpc: + git: + url: https://github.com/KRTirtho/frb_plugins.git + path: packages/flutter_discord_rpc + ref: cargokit html_unescape: ^2.0.0 wikipedia_api: ^0.1.0 skeletonizer: ^1.1.1 @@ -109,10 +121,7 @@ dependencies: flutter_sharing_intent: ^1.1.0 flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 - spotify: - git: - url: https://github.com/KRTirtho/spotify-dart.git - ref: fix/explicit-to-json + spotify: ^0.13.7 bonsoir: ^5.1.9 shelf: ^1.4.1 shelf_router: ^1.1.4 From d515f3d3be9c6a50faadfbe6d3b6b12064d070f1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 1 Aug 2024 16:46:26 +0600 Subject: [PATCH 191/261] cd: fix rustup target failing for ios --- .github/Dockerfile | 2 +- .github/Dockerfile.flutter_distributor | 23 ----------------------- cli/commands/install-dependencies.dart | 5 +++++ 3 files changed, 6 insertions(+), 24 deletions(-) delete mode 100644 .github/Dockerfile.flutter_distributor diff --git a/.github/Dockerfile b/.github/Dockerfile index 2e393449..4d0645d1 100644 --- a/.github/Dockerfile +++ b/.github/Dockerfile @@ -1,6 +1,6 @@ ARG FLUTTER_VERSION -FROM --platform=linux/arm64 krtirtho/flutter_distributor_arm64:${FLUTTER_VERSION} +FROM --platform=linux/arm64 krtirtho/flutter_distributor:${FLUTTER_VERSION} ARG BUILD_VERSION diff --git a/.github/Dockerfile.flutter_distributor b/.github/Dockerfile.flutter_distributor deleted file mode 100644 index d842e533..00000000 --- a/.github/Dockerfile.flutter_distributor +++ /dev/null @@ -1,23 +0,0 @@ -FROM --platform=linux/arm64 ubuntu:22.04 - -ARG FLUTTER_VERSION - -RUN apt-get clean &&\ - apt-get update &&\ - apt-get install -y bash curl file git unzip xz-utils zip libglu1-mesa cmake tar clang ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse libunwind-dev locate patchelf gir1.2-appindicator3-0.1 libappindicator3-1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev mpv libmpv-dev rpm libwebkit2gtk-4.1-0 libwebkit2gtk-4.1-dev libsoup-3.0-0 libsoup-3.0-dev && \ - rm -rf /var/lib/apt/lists/* - -WORKDIR /home/flutter - -RUN git clone https://github.com/flutter/flutter.git -b ${FLUTTER_VERSION} --single-branch flutter-sdk - -RUN flutter-sdk/bin/flutter precache - -RUN flutter-sdk/bin/flutter config --no-analytics - -ENV PATH="$PATH:/home/flutter/flutter-sdk/bin" -ENV PATH="$PATH:/home/flutter/flutter-sdk/bin/cache/dart-sdk/bin" -ENV PATH="$PATH:/home/flutter/.pub-cache/bin" -ENV PUB_CACHE="/home/flutter/.pub-cache" - -RUN dart pub global activate flutter_distributor \ No newline at end of file diff --git a/cli/commands/install-dependencies.dart b/cli/commands/install-dependencies.dart index 6875e35f..dc519cc6 100644 --- a/cli/commands/install-dependencies.dart +++ b/cli/commands/install-dependencies.dart @@ -58,6 +58,11 @@ class InstallDependenciesCommand extends Command { ); break; case "ios": + await shell.run( + """ + rustup target add aarch64-apple-ios + """, + ); break; case "android": await shell.run( From 0d537abab3dbadee3e9f3c1c6d91cedad500651a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 1 Aug 2024 17:19:17 +0600 Subject: [PATCH 192/261] cd: disable arm64 --- .github/workflows/spotube-release-binary.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 8fcf228a..72f06041 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -36,11 +36,11 @@ jobs: dist/Spotube-linux-x86_64.deb dist/Spotube-linux-x86_64.rpm dist/spotube-linux-*-x86_64.tar.xz - - os: ubuntu-latest - platform: linux_arm - files: | - dist/Spotube-linux-aarch64.deb - dist/spotube-linux-*-aarch64.tar.xz + # - os: ubuntu-latest + # platform: linux_arm + # files: | + # dist/Spotube-linux-aarch64.deb + # dist/spotube-linux-*-aarch64.tar.xz - os: ubuntu-latest platform: android files: | From 4b65319879127e70a155af91217ac795e685e4f1 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 4 Aug 2024 10:27:05 +0600 Subject: [PATCH 193/261] chore: pubspec update --- pubspec.lock | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index b21c0091..bd0ab415 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -715,9 +715,11 @@ packages: flutter_discord_rpc: dependency: "direct main" description: - path: "../frb_plugins/packages/flutter_discord_rpc" - relative: true - source: path + path: "packages/flutter_discord_rpc" + ref: cargokit + resolved-ref: "7ca0bbb786d8ce0e4bf8341b673b6c709ba69146" + url: "https://github.com/KRTirtho/frb_plugins.git" + source: git version: "0.1.0+1" flutter_displaymode: dependency: "direct main" @@ -1480,9 +1482,11 @@ packages: metadata_god: dependency: "direct main" description: - path: "../frb_plugins/packages/metadata_god" - relative: true - source: path + path: "packages/metadata_god" + ref: cargokit + resolved-ref: "7ca0bbb786d8ce0e4bf8341b673b6c709ba69146" + url: "https://github.com/KRTirtho/frb_plugins.git" + source: git version: "0.5.3" mime: dependency: "direct main" @@ -1981,9 +1985,11 @@ packages: smtc_windows: dependency: "direct main" description: - path: "../frb_plugins/packages/smtc_windows" - relative: true - source: path + path: "packages/smtc_windows" + ref: cargokit + resolved-ref: "7ca0bbb786d8ce0e4bf8341b673b6c709ba69146" + url: "https://github.com/KRTirtho/frb_plugins.git" + source: git version: "0.1.3" source_gen: dependency: transitive From 04650422640730537dfcb7347f31d697b85bfa65 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 4 Aug 2024 11:32:50 +0600 Subject: [PATCH 194/261] cd: re-enable arm --- .github/workflows/spotube-release-binary.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 72f06041..3d8fee5a 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -36,11 +36,11 @@ jobs: dist/Spotube-linux-x86_64.deb dist/Spotube-linux-x86_64.rpm dist/spotube-linux-*-x86_64.tar.xz - # - os: ubuntu-latest - # platform: linux_arm - # files: | - # dist/Spotube-linux-aarch64.deb - # dist/spotube-linux-*-aarch64.tar.xz + - os: ubuntu-latest + platform: linux_arm + files: | + dist/Spotube-linux-aarch64.deb + dist/spotube-linux-*-aarch64.tar.xz - os: ubuntu-latest platform: android files: | @@ -83,6 +83,7 @@ jobs: if: ${{matrix.platform == 'linux_arm'}} uses: docker/setup-buildx-action@v3 - name: Setup Rust toolchain + if: ${{matrix.platform != 'linux_arm'}} uses: dtolnay/rust-toolchain@stable with: toolchain: stable From b5f3894983b5a3579f53dddbd6859963c6ebc5a0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 4 Aug 2024 12:03:48 +0600 Subject: [PATCH 195/261] cd: add aarch64-unknown-linux-gnu manually in dockerfile for linux arm --- .github/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/Dockerfile b/.github/Dockerfile index 4d0645d1..f6a9f538 100644 --- a/.github/Dockerfile +++ b/.github/Dockerfile @@ -10,6 +10,8 @@ COPY . . RUN chown -R $(whoami) /app +RUN rustup target add aarch64-unknown-linux-gnu + RUN flutter pub get RUN alias dpkg-deb="dpkg-deb --Zxz" &&\ From 1c66646798ecc6835df9e48d3b3923ee8690352a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 7 Aug 2024 21:54:47 +0600 Subject: [PATCH 196/261] fix(windows): local tracks plays but disabled playback controls --- lib/pages/library/local_folder.dart | 3 +- lib/provider/audio_player/audio_player.dart | 6 ++- lib/services/audio_player/audio_player.dart | 37 +++++++++++-------- .../audio_players_streams_mixin.dart | 5 ++- linux/flutter/generated_plugin_registrant.cc | 4 -- linux/flutter/generated_plugins.cmake | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 2 - pubspec.lock | 15 ++------ pubspec.yaml | 11 ------ .../flutter/generated_plugin_registrant.cc | 3 -- windows/flutter/generated_plugins.cmake | 1 - 11 files changed, 35 insertions(+), 53 deletions(-) diff --git a/lib/pages/library/local_folder.dart b/lib/pages/library/local_folder.dart index 16891bc1..ad1d5d82 100644 --- a/lib/pages/library/local_folder.dart +++ b/lib/pages/library/local_folder.dart @@ -37,9 +37,10 @@ class LocalLibraryPage extends HookConsumerWidget { currentTrack ??= tracks.first; final isPlaylistPlaying = playlist.containsTracks(tracks); if (!isPlaylistPlaying) { + var indexWhere = tracks.indexWhere((s) => s.id == currentTrack?.id); await playback.load( tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + initialIndex: indexWhere, autoPlay: true, ); } else if (isPlaylistPlaying && diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index 437b666e..3bc71942 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart' hide Track; @@ -11,6 +12,7 @@ import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/discord_provider.dart'; +import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -283,7 +285,7 @@ class AudioPlayerNotifier extends Notifier { // Giving the initial track a boost so MediaKit won't skip // because of timeout final intendedActiveTrack = medias.elementAt(initialIndex); - if (intendedActiveTrack is! LocalTrack) { + if (intendedActiveTrack.track is! LocalTrack) { await ref.read(sourcedTrackProvider(intendedActiveTrack).future); } @@ -292,7 +294,7 @@ class AudioPlayerNotifier extends Notifier { await removeCollections(state.collections); await audioPlayer.openPlaylist( - medias, + medias.map((s) => s as Media).toList(), initialIndex: initialIndex, autoPlay: autoPlay, ); diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart index 7915dc3b..4febecdf 100644 --- a/lib/services/audio_player/audio_player.dart +++ b/lib/services/audio_player/audio_player.dart @@ -41,9 +41,16 @@ class SpotubeMedia extends mk.Media { ); @override - String get uri => track is LocalTrack - ? (track as LocalTrack).path - : "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}"; + String get uri { + return switch (track) { + /// [super.uri] must be used instead of [track.path] to prevent wrong + /// path format exceptions in Windows causing [extras] to be null + LocalTrack() => super.uri, + _ => + "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:" + "$serverPort/stream/${track.id}", + }; + } factory SpotubeMedia.fromMedia(mk.Media media) { final track = media.uri.startsWith("http") @@ -56,20 +63,20 @@ class SpotubeMedia extends mk.Media { ); } - @override - operator ==(Object other) { - if (other is! SpotubeMedia) return false; + // @override + // operator ==(Object other) { + // if (other is! SpotubeMedia) return false; - final isLocal = track is LocalTrack && other.track is LocalTrack; - return isLocal - ? (other.track as LocalTrack).path == (track as LocalTrack).path - : other.track.id == track.id; - } + // final isLocal = track is LocalTrack && other.track is LocalTrack; + // return isLocal + // ? (other.track as LocalTrack).path == (track as LocalTrack).path + // : other.track.id == track.id; + // } - @override - int get hashCode => track is LocalTrack - ? (track as LocalTrack).path.hashCode - : track.id.hashCode; + // @override + // int get hashCode => track is LocalTrack + // ? (track as LocalTrack).path.hashCode + // : track.id.hashCode; } abstract class AudioPlayerInterface { diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index 03ce0d5d..6b9616fa 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -149,5 +149,8 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { Stream get errorStream => _mkPlayer.stream.error; - Stream get playlistStream => _mkPlayer.stream.playlist; + Stream get playlistStream => _mkPlayer.stream.playlist.map((s){ + print("[Stream Playlist]: $s"); + return s; + }); } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 5550ed22..0f93d754 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -15,7 +15,6 @@ #include #include #include -#include #include #include #include @@ -48,9 +47,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) system_theme_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SystemThemePlugin"); system_theme_plugin_register_with_registrar(system_theme_registrar); - g_autoptr(FlPluginRegistrar) system_tray_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "SystemTrayPlugin"); - system_tray_plugin_register_with_registrar(system_tray_registrar); g_autoptr(FlPluginRegistrar) tray_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); tray_manager_plugin_register_with_registrar(tray_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 93bf6bc0..ff642696 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -12,7 +12,6 @@ list(APPEND FLUTTER_PLUGIN_LIST screen_retriever sqlite3_flutter_libs system_theme - system_tray tray_manager url_launcher_linux window_manager diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8a65bb53..ea94bf6d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -23,7 +23,6 @@ import shared_preferences_foundation import sqflite import sqlite3_flutter_libs import system_theme -import system_tray import tray_manager import url_launcher_macos import window_manager @@ -47,7 +46,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) SystemThemePlugin.register(with: registry.registrar(forPlugin: "SystemThemePlugin")) - SystemTrayPlugin.register(with: registry.registrar(forPlugin: "SystemTrayPlugin")) TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) diff --git a/pubspec.lock b/pubspec.lock index bd0ab415..d1d9c780 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -717,7 +717,7 @@ packages: description: path: "packages/flutter_discord_rpc" ref: cargokit - resolved-ref: "7ca0bbb786d8ce0e4bf8341b673b6c709ba69146" + resolved-ref: ed8d0d67fae7a23acc9b58c84f01d02abd8d8515 url: "https://github.com/KRTirtho/frb_plugins.git" source: git version: "0.1.0+1" @@ -1484,7 +1484,7 @@ packages: description: path: "packages/metadata_god" ref: cargokit - resolved-ref: "7ca0bbb786d8ce0e4bf8341b673b6c709ba69146" + resolved-ref: ed8d0d67fae7a23acc9b58c84f01d02abd8d8515 url: "https://github.com/KRTirtho/frb_plugins.git" source: git version: "0.5.3" @@ -1987,7 +1987,7 @@ packages: description: path: "packages/smtc_windows" ref: cargokit - resolved-ref: "7ca0bbb786d8ce0e4bf8341b673b6c709ba69146" + resolved-ref: ed8d0d67fae7a23acc9b58c84f01d02abd8d8515 url: "https://github.com/KRTirtho/frb_plugins.git" source: git version: "0.1.3" @@ -2151,15 +2151,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" - system_tray: - dependency: "direct overridden" - description: - path: "." - ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c - resolved-ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c - url: "https://github.com/antler119/system_tray" - source: git - version: "2.0.2" term_glyph: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 983d4001..e4b72df4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -164,17 +164,6 @@ dev_dependencies: dependency_overrides: uuid: ^4.4.0 - system_tray: - # TODO: remove this when flutter_desktop_tools gets updated - # to use [MenuItemBase] instead of [MenuItem] - git: - url: https://github.com/antler119/system_tray - ref: dc7ef410d5cfec897edf060c1c4baff69f7c181c - # media_kit_native_event_loop: # to fix "macro name must be an identifier" - # git: - # url: https://github.com/media-kit/media-kit - # path: media_kit_native_event_loop - # ref: main flutter: generate: true diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 217a7cb4..f2d60e21 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -45,8 +44,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); SystemThemePluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("SystemThemePlugin")); - SystemTrayPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SystemTrayPlugin")); TrayManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("TrayManagerPlugin")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index cbbd2acc..bea4d801 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -14,7 +14,6 @@ list(APPEND FLUTTER_PLUGIN_LIST screen_retriever sqlite3_flutter_libs system_theme - system_tray tray_manager url_launcher_windows window_manager From ce19ef1efd7af2506460cbbaf4dc32d96f350b73 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 7 Aug 2024 22:12:57 +0600 Subject: [PATCH 197/261] chore: upgrade plugin versions --- pubspec.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index d1d9c780..5fce257f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -717,7 +717,7 @@ packages: description: path: "packages/flutter_discord_rpc" ref: cargokit - resolved-ref: ed8d0d67fae7a23acc9b58c84f01d02abd8d8515 + resolved-ref: "1b839bf02afd5dfa56b0dc25f60af04aa9bfc7c3" url: "https://github.com/KRTirtho/frb_plugins.git" source: git version: "0.1.0+1" @@ -1484,7 +1484,7 @@ packages: description: path: "packages/metadata_god" ref: cargokit - resolved-ref: ed8d0d67fae7a23acc9b58c84f01d02abd8d8515 + resolved-ref: "1b839bf02afd5dfa56b0dc25f60af04aa9bfc7c3" url: "https://github.com/KRTirtho/frb_plugins.git" source: git version: "0.5.3" @@ -1987,7 +1987,7 @@ packages: description: path: "packages/smtc_windows" ref: cargokit - resolved-ref: ed8d0d67fae7a23acc9b58c84f01d02abd8d8515 + resolved-ref: "1b839bf02afd5dfa56b0dc25f60af04aa9bfc7c3" url: "https://github.com/KRTirtho/frb_plugins.git" source: git version: "0.1.3" From ebaf5615ad511867c46b567e6293d96360842b22 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 9 Aug 2024 13:13:11 +0600 Subject: [PATCH 198/261] cd: free up space for linux arm --- .github/workflows/spotube-release-binary.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 3d8fee5a..b103ea2e 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -99,6 +99,11 @@ jobs: echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks echo '${{ secrets.KEY_PROPERTIES }}' > android/key.properties + - name: Unessary hosted tools + if: ${{matrix.platform == 'linux_arm'}} + run: | + sudo rm -rf /usr/share/dotnet + - name: Build ${{matrix.platform}} binaries run: dart cli/cli.dart build ${{matrix.platform}} env: From 1cc7882177203d345bb732714b742655eb8c494a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 9 Aug 2024 19:55:56 +0600 Subject: [PATCH 199/261] fix(windows): app crashes when no internet --- pubspec.lock | 24 ++++++++++++------------ pubspec.yaml | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 5fce257f..cd96e007 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -125,50 +125,50 @@ packages: dependency: "direct main" description: name: bonsoir - sha256: "9703ca3ce201c7ab6cd278ae5a530a125959687f59c2b97822f88a8db5bef106" + sha256: b7697a954c772a6ddc68d52b3e4768947cc98613127f7720a05b14ed1e59d68b url: "https://pub.dev" source: hosted - version: "5.1.9" + version: "5.1.10" bonsoir_android: dependency: transitive description: name: bonsoir_android - sha256: "19583ae34a5e5743fa2c16619e4ec699b35ae5e6cece59b99b1cf21c1b4ed618" + sha256: a72d83a78780c1f238e3178d0585e5604fbd9f2503206293737cdfab899ce8d0 url: "https://pub.dev" source: hosted - version: "5.1.4" + version: "5.1.5" bonsoir_darwin: dependency: transitive description: name: bonsoir_darwin - sha256: "985c4c38b4cbfa57ed5870e724a7e17aa080ee7f49d03b43e6d08781511505c6" + sha256: "2d25c70f0d09260be1c2ab583b80dd89cbbfd59997579dadf789c5af00c7b2e4" url: "https://pub.dev" source: hosted - version: "5.1.2" + version: "5.1.3" bonsoir_linux: dependency: transitive description: name: bonsoir_linux - sha256: "65554b20bc169c68c311eb31fab46ccdd8ee3d3dd89a2d57c338f4cbf6ceb00d" + sha256: f2639aded6e15943a9822de98a663a1056f37cbfd0a74d72c9eaa941965945c2 url: "https://pub.dev" source: hosted - version: "5.1.2" + version: "5.1.3" bonsoir_platform_interface: dependency: transitive description: name: bonsoir_platform_interface - sha256: "4ee898bec0b5a63f04f82b06da9896ae8475f32a33b6fa395bea56399daeb9f0" + sha256: "08bb8b35d0198168b3bce87dbc718e4e510336cff1d97e43762e030c01636d45" url: "https://pub.dev" source: hosted - version: "5.1.2" + version: "5.1.3" bonsoir_windows: dependency: transitive description: name: bonsoir_windows - sha256: abbc90b73ac39e823b0c127da43b91d8906dcc530fc0cec4e169cf0d8c4404b1 + sha256: d4a0ca479d4f3679487a61f3174fb9fe1651e323c778b02dfa630490366be65d url: "https://pub.dev" source: hosted - version: "5.1.4" + version: "5.1.5" boolean_selector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e4b72df4..7f6f0f29 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -122,7 +122,7 @@ dependencies: flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 spotify: ^0.13.7 - bonsoir: ^5.1.9 + bonsoir: ^5.1.10 shelf: ^1.4.1 shelf_router: ^1.1.4 shelf_web_socket: ^1.0.4 From 39ea7a701c3aaf3ba74d99d7778d97ecc92d7db5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 9 Aug 2024 20:07:24 +0600 Subject: [PATCH 200/261] chore: remove unnecessary print statements --- lib/services/audio_player/audio_players_streams_mixin.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index 6b9616fa..3995acf7 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -149,8 +149,7 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { Stream get errorStream => _mkPlayer.stream.error; - Stream get playlistStream => _mkPlayer.stream.playlist.map((s){ - print("[Stream Playlist]: $s"); + Stream get playlistStream => _mkPlayer.stream.playlist.map((s) { return s; - }); + }); } From 123eb168a3d548e4474cb70466e67bf6dc879408 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 9 Aug 2024 22:41:29 +0600 Subject: [PATCH 201/261] fix(linux): tray icon wrong name for flatpak --- lib/provider/tray_manager/tray_manager.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/provider/tray_manager/tray_manager.dart b/lib/provider/tray_manager/tray_manager.dart index 2145cbef..9cc4becc 100644 --- a/lib/provider/tray_manager/tray_manager.dart +++ b/lib/provider/tray_manager/tray_manager.dart @@ -24,7 +24,7 @@ class SystemTrayManager with TrayListener { kIsWindows ? 'assets/spotube-logo.ico' : kIsFlatpak - ? 'com.github.KRTirtho.Spotube.png' + ? 'com.github.KRTirtho.Spotube' : 'assets/spotube-logo.png', ); trayManager.addListener(this); From 6456b43d10726660166dd6a0b66df2f94b7f1c43 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 10 Aug 2024 20:53:17 +0600 Subject: [PATCH 202/261] refactor: logs page show full log --- lib/pages/settings/logs.dart | 128 +++++---------------------- lib/provider/logs/logs_provider.dart | 12 +++ macos/Podfile.lock | 6 -- 3 files changed, 35 insertions(+), 111 deletions(-) create mode 100644 lib/provider/logs/logs_provider.dart diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index a49050ad..91087b7e 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -1,77 +1,23 @@ -import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; -import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/provider/logs/logs_provider.dart'; -class LogsPage extends HookWidget { +class LogsPage extends HookConsumerWidget { static const name = "logs"; const LogsPage({super.key}); - List<({DateTime? date, String body})> parseLogs(String raw) { - return raw - .split( - "======================================================================", - ) - .map( - (line) { - DateTime? date; - line = line - .replaceAll( - "============================== CATCHER LOG ==============================", - "", - ) - .split("\n") - .map((l) { - if (l.startsWith("Crash occurred on")) { - date = DateTime.parse( - l.split("Crash occurred on")[1].trim(), - ); - return ""; - } - return l; - }) - .where((l) => l.replaceAll("\n", "").trim().isNotEmpty) - .join("\n"); - - return ( - date: date, - body: line, - ); - }, - ) - .where((e) => e.date != null && e.body.isNotEmpty) - .toList() - ..sort((a, b) => b.date!.compareTo(a.date!)); - } - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, ref) { final controller = useScrollController(); - final logs = useState>([]); - final rawLogs = useRef(""); - final path = useRef(null); - useEffect(() { - final timer = Timer.periodic(const Duration(seconds: 5), (t) async { - path.value ??= await AppLogger.getLogsPath(); - final raw = await path.value!.readAsString(); - final hasChanged = rawLogs.value != raw; - rawLogs.value = raw; - if (hasChanged) logs.value = parseLogs(rawLogs.value); - }); - - return () { - timer.cancel(); - }; - }, []); + final logsQuery = ref.watch(logsProvider); return Scaffold( appBar: PageWindowTitleBar( @@ -82,7 +28,9 @@ class LogsPage extends HookWidget { icon: const Icon(SpotubeIcons.clipboard), iconSize: 16, onPressed: () async { - await Clipboard.setData(ClipboardData(text: rawLogs.value)); + final logsSnapshot = await ref.read(logsProvider.future); + + await Clipboard.setData(ClipboardData(text: logsSnapshot)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -95,52 +43,22 @@ class LogsPage extends HookWidget { ], ), body: SafeArea( - child: InterScrollbar( - controller: controller, - child: ListView.builder( - controller: controller, - itemCount: logs.value.length, - itemBuilder: (context, index) { - final log = logs.value[index]; - return Stack( - children: [ - SectionCardWithHeading( - heading: log.date.toString(), - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: SelectableText(log.body), - ), - ], + child: switch (logsQuery) { + AsyncData(:final value) => Card( + child: InterScrollbar( + controller: controller, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: SingleChildScrollView( + controller: controller, + child: Text(value), ), - Positioned( - right: 10, - top: 0, - child: IconButton( - icon: const Icon(SpotubeIcons.clipboard), - onPressed: () async { - await Clipboard.setData( - ClipboardData(text: log.body), - ); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.l10n.copied_to_clipboard( - log.date.toString(), - ), - ), - ), - ); - } - }, - ), - ), - ], - ); - }, - ), - ), + ), + ), + ), + AsyncError(:final error) => Center(child: Text(error.toString())), + _ => const Center(child: CircularProgressIndicator()), + }, ), ); } diff --git a/lib/provider/logs/logs_provider.dart b/lib/provider/logs/logs_provider.dart new file mode 100644 index 00000000..b0e95cae --- /dev/null +++ b/lib/provider/logs/logs_provider.dart @@ -0,0 +1,12 @@ +import 'dart:convert'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/services/logger/logger.dart'; + +final logsProvider = StreamProvider.autoDispose((ref) async* { + final file = await AppLogger.getLogsPath(); + final stream = file.openRead().transform(utf8.decoder); + await for (final line in stream) { + yield line; + } +}); diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 9b122cbe..b3092d8c 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -61,8 +61,6 @@ PODS: - sqlite3/rtree - system_theme (0.0.1): - FlutterMacOS - - system_tray (0.0.1): - - FlutterMacOS - tray_manager (0.0.1): - FlutterMacOS - url_launcher_macos (0.0.1): @@ -93,7 +91,6 @@ DEPENDENCIES: - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos`) - system_theme (from `Flutter/ephemeral/.symlinks/plugins/system_theme/macos`) - - system_tray (from `Flutter/ephemeral/.symlinks/plugins/system_tray/macos`) - tray_manager (from `Flutter/ephemeral/.symlinks/plugins/tray_manager/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) @@ -148,8 +145,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos system_theme: :path: Flutter/ephemeral/.symlinks/plugins/system_theme/macos - system_tray: - :path: Flutter/ephemeral/.symlinks/plugins/system_tray/macos tray_manager: :path: Flutter/ephemeral/.symlinks/plugins/tray_manager/macos url_launcher_macos: @@ -182,7 +177,6 @@ SPEC CHECKSUMS: sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 sqlite3_flutter_libs: 1be4459672f8168ded2d8667599b8e3ca5e72b83 system_theme: c7b9f6659a5caa26c9bc2284da096781e9a6fcbc - system_tray: e53c972838c69589ff2e77d6d3abfd71332f9e5d tray_manager: 9064e219c56d75c476e46b9a21182087930baf90 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 From 64d25509b4aa603a451532cbf8369b71e11f3659 Mon Sep 17 00:00:00 2001 From: nexpid <60316309+nexpid@users.noreply.github.com> Date: Sat, 10 Aug 2024 17:30:11 +0200 Subject: [PATCH 203/261] feat(discord): album art, playing time and play pause support (#1765) --- lib/provider/discord_provider.dart | 60 +++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index 1e819af0..23be0bc3 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -6,6 +6,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; class DiscordNotifier extends AsyncNotifier { @@ -13,17 +14,39 @@ class DiscordNotifier extends AsyncNotifier { FutureOr build() async { final enabled = ref.watch( userPreferencesProvider.select((s) => s.discordPresence && kIsDesktop)); - final playback = ref.read(audioPlayerProvider); - final subscription = - FlutterDiscordRPC.instance.isConnectedStream.listen((connected) async { - if (connected && playback.activeTrack != null) { - await updatePresence(playback.activeTrack!); - } - }); + var lastPosition = audioPlayer.position; + + final subscriptions = + [ + FlutterDiscordRPC.instance.isConnectedStream.listen((connected) async { + final playback = ref.read(audioPlayerProvider); + if (connected && playback.activeTrack != null) { + await updatePresence(playback.activeTrack!); + } + }), + audioPlayer.playerStateStream.listen((state) async { + final playback = ref.read(audioPlayerProvider); + if (playback.activeTrack == null) return; + + await updatePresence(ref.read(audioPlayerProvider).activeTrack!); + }), + audioPlayer.positionStream.listen((position) async { + final playback = ref.read(audioPlayerProvider); + if (playback.activeTrack != null) { + final diff = position.inMilliseconds - lastPosition.inMilliseconds; + if (diff > 500 || diff < -500) { + await updatePresence(ref.read(audioPlayerProvider).activeTrack!); + } + } + lastPosition = position; + }) + ]; ref.onDispose(() async { - subscription.cancel(); + for (final subscription in subscriptions) { + subscription.cancel(); + } await close(); await FlutterDiscordRPC.instance.dispose(); }); @@ -37,15 +60,18 @@ class DiscordNotifier extends AsyncNotifier { } Future updatePresence(Track track) async { - await clear(); - final artistNames = track.artists?.asString() ?? ""; + final artistNames = track.artists?.asString(); + final isPlaying = audioPlayer.isPlaying; + final position = audioPlayer.position; + await FlutterDiscordRPC.instance.setActivity( activity: RPCActivity( - details: "${track.name} by $artistNames", - state: "Vibing in Music", - assets: const RPCAssets( - largeImage: "spotube-logo-foreground", - largeText: "Spotube", + details: track.name, + state: artistNames != null ? "by $artistNames" : null, + assets: RPCAssets( + largeImage: + track.album?.images?.first.url ?? "spotube-logo-foreground", + largeText: track.album?.name ?? "Unknown album", smallImage: "spotube-logo-foreground", smallText: "Spotube", ), @@ -57,7 +83,7 @@ class DiscordNotifier extends AsyncNotifier { ), ], timestamps: RPCTimestamps( - start: DateTime.now().millisecondsSinceEpoch, + start: isPlaying ? DateTime.now().millisecondsSinceEpoch - position.inMilliseconds : null, ), ), ); @@ -73,4 +99,4 @@ class DiscordNotifier extends AsyncNotifier { } final discordProvider = - AsyncNotifierProvider(() => DiscordNotifier()); + AsyncNotifierProvider(() => DiscordNotifier()); \ No newline at end of file From 84f47df6c16c0ba1c35f2bde0b1c26fbb0f07168 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 10 Aug 2024 21:35:38 +0600 Subject: [PATCH 204/261] feat(discord): add listening activity type --- lib/provider/discord_provider.dart | 52 ++++++++++++++++-------------- pubspec.lock | 6 ++-- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index 23be0bc3..8f8cb375 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -17,31 +17,30 @@ class DiscordNotifier extends AsyncNotifier { var lastPosition = audioPlayer.position; - final subscriptions = - [ - FlutterDiscordRPC.instance.isConnectedStream.listen((connected) async { - final playback = ref.read(audioPlayerProvider); - if (connected && playback.activeTrack != null) { - await updatePresence(playback.activeTrack!); - } - }), - audioPlayer.playerStateStream.listen((state) async { - final playback = ref.read(audioPlayerProvider); - if (playback.activeTrack == null) return; + final subscriptions = [ + FlutterDiscordRPC.instance.isConnectedStream.listen((connected) async { + final playback = ref.read(audioPlayerProvider); + if (connected && playback.activeTrack != null) { + await updatePresence(playback.activeTrack!); + } + }), + audioPlayer.playerStateStream.listen((state) async { + final playback = ref.read(audioPlayerProvider); + if (playback.activeTrack == null) return; + await updatePresence(ref.read(audioPlayerProvider).activeTrack!); + }), + audioPlayer.positionStream.listen((position) async { + final playback = ref.read(audioPlayerProvider); + if (playback.activeTrack != null) { + final diff = position.inMilliseconds - lastPosition.inMilliseconds; + if (diff > 500 || diff < -500) { await updatePresence(ref.read(audioPlayerProvider).activeTrack!); - }), - audioPlayer.positionStream.listen((position) async { - final playback = ref.read(audioPlayerProvider); - if (playback.activeTrack != null) { - final diff = position.inMilliseconds - lastPosition.inMilliseconds; - if (diff > 500 || diff < -500) { - await updatePresence(ref.read(audioPlayerProvider).activeTrack!); - } - } - lastPosition = position; - }) - ]; + } + } + lastPosition = position; + }) + ]; ref.onDispose(() async { for (final subscription in subscriptions) { @@ -83,8 +82,11 @@ class DiscordNotifier extends AsyncNotifier { ), ], timestamps: RPCTimestamps( - start: isPlaying ? DateTime.now().millisecondsSinceEpoch - position.inMilliseconds : null, + start: isPlaying + ? DateTime.now().millisecondsSinceEpoch - position.inMilliseconds + : null, ), + activityType: ActivityType.listening, ), ); } @@ -99,4 +101,4 @@ class DiscordNotifier extends AsyncNotifier { } final discordProvider = - AsyncNotifierProvider(() => DiscordNotifier()); \ No newline at end of file + AsyncNotifierProvider(() => DiscordNotifier()); diff --git a/pubspec.lock b/pubspec.lock index cd96e007..0bc24dff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -717,7 +717,7 @@ packages: description: path: "packages/flutter_discord_rpc" ref: cargokit - resolved-ref: "1b839bf02afd5dfa56b0dc25f60af04aa9bfc7c3" + resolved-ref: "331636d8e378e3ac9ad30a4b0d3eed17d5a85fe9" url: "https://github.com/KRTirtho/frb_plugins.git" source: git version: "0.1.0+1" @@ -1484,7 +1484,7 @@ packages: description: path: "packages/metadata_god" ref: cargokit - resolved-ref: "1b839bf02afd5dfa56b0dc25f60af04aa9bfc7c3" + resolved-ref: "331636d8e378e3ac9ad30a4b0d3eed17d5a85fe9" url: "https://github.com/KRTirtho/frb_plugins.git" source: git version: "0.5.3" @@ -1987,7 +1987,7 @@ packages: description: path: "packages/smtc_windows" ref: cargokit - resolved-ref: "1b839bf02afd5dfa56b0dc25f60af04aa9bfc7c3" + resolved-ref: "331636d8e378e3ac9ad30a4b0d3eed17d5a85fe9" url: "https://github.com/KRTirtho/frb_plugins.git" source: git version: "0.1.3" From 388e2d0289cfb11d21c096cc20d463f34a9b803e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 10 Aug 2024 21:50:20 +0600 Subject: [PATCH 205/261] fix(ios): permission exception --- ios/Podfile.lock | 48 ++++++++++++------- .../configurators/use_get_storage_perms.dart | 40 ++++++++++------ 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index f8533902..7e5f24b5 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -49,6 +49,8 @@ PODS: - Flutter (1.0.0) - flutter_broadcasts (0.0.1): - Flutter + - flutter_discord_rpc (0.0.1): + - Flutter - flutter_inappwebview_ios (0.0.1): - Flutter - flutter_inappwebview_ios/Core (= 0.0.1) @@ -58,17 +60,12 @@ PODS: - OrderedSet (~> 5.0) - flutter_keyboard_visibility (0.0.1): - Flutter - - flutter_mailer (0.0.1): - - Flutter - flutter_native_splash (0.0.1): - Flutter - flutter_secure_storage (6.0.0): - Flutter - flutter_sharing_intent (0.0.1): - Flutter - - fluttertoast (0.0.2): - - Flutter - - Toast - image_picker_ios (0.0.1): - Flutter - integration_test (0.0.1): @@ -77,7 +74,8 @@ PODS: - Flutter - media_kit_native_event_loop (1.0.0): - Flutter - - metadata_god (0.0.1) + - metadata_god (0.0.1): + - Flutter - OrderedSet (5.0.0) - package_info_plus (0.4.5): - Flutter @@ -95,8 +93,22 @@ PODS: - sqflite (0.0.3): - Flutter - FlutterMacOS + - "sqlite3 (3.46.0+1)": + - "sqlite3/common (= 3.46.0+1)" + - "sqlite3/common (3.46.0+1)" + - "sqlite3/fts5 (3.46.0+1)": + - sqlite3/common + - "sqlite3/perf-threadsafe (3.46.0+1)": + - sqlite3/common + - "sqlite3/rtree (3.46.0+1)": + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - sqlite3 (~> 3.46.0) + - sqlite3/fts5 + - sqlite3/perf-threadsafe + - sqlite3/rtree - SwiftyGif (5.4.4) - - Toast (4.0.0) - url_launcher_ios (0.0.1): - Flutter @@ -110,13 +122,12 @@ DEPENDENCIES: - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`) + - flutter_discord_rpc (from `.symlinks/plugins/flutter_discord_rpc/ios`) - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - - flutter_mailer (from `.symlinks/plugins/flutter_mailer/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`) - - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - media_kit_libs_ios_audio (from `.symlinks/plugins/media_kit_libs_ios_audio/ios`) @@ -127,6 +138,7 @@ DEPENDENCIES: - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite (from `.symlinks/plugins/sqflite/darwin`) + - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: @@ -135,8 +147,8 @@ SPEC REPOS: - DKPhotoGallery - OrderedSet - SDWebImage + - sqlite3 - SwiftyGif - - Toast EXTERNAL SOURCES: app_links: @@ -157,20 +169,18 @@ EXTERNAL SOURCES: :path: Flutter flutter_broadcasts: :path: ".symlinks/plugins/flutter_broadcasts/ios" + flutter_discord_rpc: + :path: ".symlinks/plugins/flutter_discord_rpc/ios" flutter_inappwebview_ios: :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" flutter_keyboard_visibility: :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" - flutter_mailer: - :path: ".symlinks/plugins/flutter_mailer/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_sharing_intent: :path: ".symlinks/plugins/flutter_sharing_intent/ios" - fluttertoast: - :path: ".symlinks/plugins/fluttertoast/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" integration_test: @@ -191,6 +201,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sqflite: :path: ".symlinks/plugins/sqflite/darwin" + sqlite3_flutter_libs: + :path: ".symlinks/plugins/sqlite3_flutter_libs/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" @@ -206,18 +218,17 @@ SPEC CHECKSUMS: file_selector_ios: 78baf21d03f1e37a7df97bb2494f9cd86de8fa5d Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 + flutter_discord_rpc: e1c342f29ceb9dd76cdc01db59a70c93bb4d9ec5 flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 - flutter_mailer: 2ef5a67087bc8c6c4cefd04a178bf1ae2c94cd83 flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 - fluttertoast: 9f2f8e81bb5ce18facb9748d7855bf5a756fe3db image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18 integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3 media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837 - metadata_god: eceae399d0020475069a5cebc35943ce8562b5d7 + metadata_god: 4bbd8523cdb5d42c5e59d2fabad01ff8f4bc53f9 OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c @@ -225,8 +236,9 @@ SPEC CHECKSUMS: SDWebImage: a81bbb3ba4ea5f810f4069c68727cb118467a04a shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec + sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 + sqlite3_flutter_libs: 0d611efdf6d1c9297d5ab03dab21b75aeebdae31 SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f - Toast: 91b396c56ee72a5790816f40d3a94dd357abc196 url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586 PODFILE CHECKSUM: 0659b64ac6e9e96b61d8550decffa8bff51a957e diff --git a/lib/hooks/configurators/use_get_storage_perms.dart b/lib/hooks/configurators/use_get_storage_perms.dart index 9cccbfe0..f860aaa7 100644 --- a/lib/hooks/configurators/use_get_storage_perms.dart +++ b/lib/hooks/configurators/use_get_storage_perms.dart @@ -12,25 +12,35 @@ void useGetStoragePermissions(WidgetRef ref) { useAsyncEffect( () async { - if (!kIsMobile) return; + if (kIsAndroid) { + final androidInfo = await DeviceInfoPlugin().androidInfo; - final androidInfo = await DeviceInfoPlugin().androidInfo; + final hasNoStoragePerm = androidInfo.version.sdkInt < 33 && + !await Permission.storage.isGranted && + !await Permission.storage.isLimited; - final hasNoStoragePerm = androidInfo.version.sdkInt < 33 && - !await Permission.storage.isGranted && - !await Permission.storage.isLimited; + final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 && + !await Permission.audio.isGranted && + !await Permission.audio.isLimited; - final hasNoAudioPerm = androidInfo.version.sdkInt >= 33 && - !await Permission.audio.isGranted && - !await Permission.audio.isLimited; - - if (hasNoStoragePerm) { - await Permission.storage.request(); - if (context.mounted) ref.invalidate(localTracksProvider); + if (hasNoStoragePerm) { + await Permission.storage.request(); + if (context.mounted) ref.invalidate(localTracksProvider); + } + if (hasNoAudioPerm) { + await Permission.audio.request(); + if (context.mounted) ref.invalidate(localTracksProvider); + } } - if (hasNoAudioPerm) { - await Permission.audio.request(); - if (context.mounted) ref.invalidate(localTracksProvider); + + if (kIsIOS) { + final hasStoragePerm = await Permission.storage.isGranted || + await Permission.storage.isLimited; + + if (!hasStoragePerm) { + await Permission.storage.request(); + if (context.mounted) ref.invalidate(localTracksProvider); + } } }, null, From 95b68687d57b27f6a6d75c51f03e79cd6a266ac1 Mon Sep 17 00:00:00 2001 From: Marat Budkevich <93652988+marat2509@users.noreply.github.com> Date: Sat, 10 Aug 2024 19:03:42 +0300 Subject: [PATCH 206/261] fix(translations): fix Russian translations (#1696) --- lib/l10n/app_ru.arb | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 0a1c1c22..2e0aa77b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -8,16 +8,16 @@ "genre_categories_filter": "Фильтр по категориям или жанрам...", "genre": "Жанр", "personalized": "Персонализированный", - "featured": "Будующий", - "new_releases": "Новые", - "songs": "Песни", + "featured": "Популярное", + "new_releases": "Новое", + "songs": "Треки", "playing_track": "Играет {track}", "queue_clear_alert": "Это удалит текущую очередь. {track_length} треков будет удалено. Вы хотите продолжить?", "load_more": "Загрузить больше", "playlists": "Плейлисты", "artists": "Исполнители", "albums": "Альбомы", - "tracks": "Трек", + "tracks": "Треки", "downloads": "Загрузки", "filter_playlists": "Применить фильтры к вашим плейлистам...", "liked_tracks": "Понравившиеся треки", @@ -25,20 +25,22 @@ "create_playlist": "Создание плейлиста", "create_a_playlist": "Создать плейлист", "create": "Создать", - "cancel": "Отменить", + "cancel": "Отмена", + "update": "Обновить", "playlist_name": "Назвать плейлист", "name_of_playlist": "Название плейлиста", "description": "Описание", - "public": "Публичные", + "public": "Публичный", "collaborative": "Совместный", "search_local_tracks": "Поиск песен на вашем устройстве...", "play": "Играть", "delete": "Удалить", - "none": "Никто", + "none": "Пусто", "sort_a_z": "Сортировка по алфавиту", "sort_z_a": "Сортировка по алфавиту в обратную сторону", "sort_artist": "Сортировать по исполнителю", "sort_album": "Сортировать по альбомам", + "sort_duration": "Сортировать по длительности", "sort_tracks": "Сортировать треки", "currently_downloading": "Загружается ({tracks_length})", "cancel_all": "Отменить все", @@ -104,6 +106,9 @@ "always_on_top": "Всегда сверху", "exit_mini_player": "Выйти из мини-плеера", "download_location": "Место загрузки", + "local_library": "Локальная библиотека", + "add_library_location": "Добавить в библиотеку", + "remove_library_location": "Удалить из библиотеки", "account": "Аккаунт", "login_with_spotify": "Войдите с помощью своей учетной записи Spotify", "connect_with_spotify": "Подключитесь к Spotify", @@ -141,7 +146,7 @@ "close": "Закрыть", "minimize_to_tray": "Свернуть", "show_tray_icon": "Показать значок на панели задач", - "about": "О", + "about": "О нас", "u_love_spotube": "Мы знаем что вам нравится Spotube", "check_for_updates": "Проверьте наличие обновлений", "about_spotube": "О Spotube", @@ -175,9 +180,11 @@ "step_2": "Шаг 2", "step_2_steps": "1. После входа в систему нажмите F12 или щелкните правой кнопкой мыши > «Проверить», чтобы открыть инструменты разработчика браузера.\n2. Затем перейдите на вкладку \"Application\" (Chrome, Edge, Brave и т.д..) or \"Storage\" (Firefox, Palemoon и т.д..)\n3. Перейдите в раздел \"Cookies\", а затем в подраздел \"https://accounts.spotify.com\"", "step_3": "Шаг 3", - "success_emoji": "Успешно 🥳", + "step_3_steps": "Скопируйте значение Cookie \"sp_dc\"", + "success_emoji": "Успешно🥳", "success_message": "Теперь вы успешно вошли в свою учетную запись Spotify. Отличная работа, приятель!", "step_4": "Шаг 4", + "step_4_steps": "Вставьте скопированное значение \"sp_dc\", "something_went_wrong": "Что-то пошло не так", "piped_instance": "Экземпляр сервера Piped", "piped_description": "Серверный экземпляр Piped для сопоставления треков", @@ -205,7 +212,7 @@ "popularity": "Популярность", "key": "Ключ", "duration": "Продолжительность (с)", - "tempo": "Время (BPM)", + "tempo": "Темп (BPM)", "mode": "Режим", "time_signature": "Тактовый размер", "short": "Короткий", @@ -257,8 +264,6 @@ "you_are_offline": "Нет доступа к сети", "connection_restored": "Ваше интернет-соединение восстановлено", "use_system_title_bar": "Использовать системную панель заголовка", - "update_playlist": "Обновить плейлист", - "update": "Обновить", "crunching_results": "Обработка результатов...", "search_to_get_results": "Поиск для получения результатов", "use_amoled_mode": "Режим AMOLED", @@ -283,11 +288,8 @@ "browse_all": "Просмотреть все", "genres": "Жанры", "explore_genres": "Исследовать жанры", - "step_3_steps": "Скопируйте значение файла cookie \"sp_dc\"", - "step_4_steps": "Вставьте скопированное значение \"sp_dc\"", "friends": "Друзья", "no_lyrics_available": "Извините, не удается найти текст для этого трека", - "sort_duration": "Сортировка по Длительности", "start_a_radio": "Запустить радио", "how_to_start_radio": "Как вы хотите запустить радио?", "replace_queue_question": "Хотите заменить текущую очередь или добавить к ней?", @@ -295,6 +297,7 @@ "delete_playlist": "Удалить плейлист", "delete_playlist_confirmation": "Вы уверены, что хотите удалить этот плейлист?", "local_tracks": "Локальные треки", + "local_tab": "Локальное", "song_link": "Ссылка на песню", "skip_this_nonsense": "Пропустить этот бред", "freedom_of_music": "“Свобода музыки”", @@ -321,9 +324,5 @@ "connect_client_alert": "Вас контролирует {client}", "this_device": "Это устройство", "remote": "Дистанционное управление", - "local_library": "Местная библиотека", - "add_library_location": "Добавить в библиотеку", - "remove_library_location": "Удалить из библиотеки", - "local_tab": "Местный", "stats": "Статистика" } \ No newline at end of file From 7408a8786019064abab302d349fabc7c5266f262 Mon Sep 17 00:00:00 2001 From: Josu Igoa Date: Sat, 10 Aug 2024 18:06:15 +0200 Subject: [PATCH 207/261] feat(translations): make state page's hard coded strings translatable (#1719) --- lib/collections/language_codes.dart | 6 +- .../dialogs/select_device_dialog.dart | 9 +-- lib/components/fallbacks/not_found.dart | 5 +- lib/components/playbutton_card.dart | 4 +- .../sections/header/header_actions.dart | 5 +- lib/l10n/app_en.arb | 61 ++++++++++++++- lib/l10n/app_eu.arb | 76 +++++++++++++++++-- lib/modules/home/sections/feed.dart | 5 +- lib/modules/home/sections/friends.dart | 3 +- lib/modules/home/sections/recent.dart | 3 +- .../playlist_generate/multi_select_field.dart | 2 +- lib/modules/player/player.dart | 3 +- lib/modules/playlist/playlist_card.dart | 5 +- .../playlist/playlist_create_dialog.dart | 2 +- lib/modules/root/update_dialog.dart | 13 ++-- .../settings/color_scheme_picker_dialog.dart | 7 +- lib/modules/stats/summary/summary.dart | 23 +++--- lib/modules/stats/top/albums.dart | 4 +- lib/modules/stats/top/artists.dart | 6 +- lib/modules/stats/top/top.dart | 29 +++---- lib/modules/stats/top/tracks.dart | 4 +- lib/pages/lyrics/lyrics.dart | 2 +- lib/pages/lyrics/synced_lyrics.dart | 2 +- lib/pages/profile/profile.dart | 22 +++--- lib/pages/settings/sections/accounts.dart | 2 +- lib/pages/stats/albums/albums.dart | 10 +-- lib/pages/stats/artists/artists.dart | 9 ++- lib/pages/stats/fees/fees.dart | 11 +-- lib/pages/stats/minutes/minutes.dart | 8 +- lib/pages/stats/playlists/playlists.dart | 9 ++- lib/pages/stats/streams/streams.dart | 8 +- 31 files changed, 251 insertions(+), 107 deletions(-) diff --git a/lib/collections/language_codes.dart b/lib/collections/language_codes.dart index f46e0efe..44da6ee6 100644 --- a/lib/collections/language_codes.dart +++ b/lib/collections/language_codes.dart @@ -83,7 +83,7 @@ abstract class LanguageLocals { // ), "eu": const ISOLanguageName( name: "Basque", - nativeName: "euskara", + nativeName: "Euskara", ), // "be": const ISOLanguageName( // name: "Belarusian", @@ -354,8 +354,8 @@ abstract class LanguageLocals { // nativeName: "KiKongo", // ), "ko": const ISOLanguageName( - name: "Korean", - nativeName: "한국어 (韓國語), 조선말 (朝鮮語)", + name: "Korean", + nativeName: "한국어 (韓國語), 조선말 (朝鮮語)", ), // "ku": const ISOLanguageName( // name: "Kurdish", diff --git a/lib/components/dialogs/select_device_dialog.dart b/lib/components/dialogs/select_device_dialog.dart index cd8dedb7..3a3bde60 100644 --- a/lib/components/dialogs/select_device_dialog.dart +++ b/lib/components/dialogs/select_device_dialog.dart @@ -15,15 +15,12 @@ class SelectDeviceDialog extends HookConsumerWidget { final remoteService = connectClients.asData!.value.resolvedService!; return AlertDialog( - title: const Text("Choose the device:"), + title: Text(context.l10n.choose_the_device), insetPadding: const EdgeInsets.all(16), content: Column( mainAxisSize: MainAxisSize.min, children: [ - const Text( - "There are multiple device connected.\n" - "Choose the device you want this action to take place", - ), + Text(context.l10n.multiple_device_connected), RadioListTile.adaptive( title: Text(remoteService.name), value: true, @@ -33,7 +30,7 @@ class SelectDeviceDialog extends HookConsumerWidget { }, ), RadioListTile.adaptive( - title: const Text("This Device"), + title: Text(context.l10n.this_device), value: false, groupValue: isRemoteService.value, onChanged: (value) { diff --git a/lib/components/fallbacks/not_found.dart b/lib/components/fallbacks/not_found.dart index 5a74f672..ce168f17 100644 --- a/lib/components/fallbacks/not_found.dart +++ b/lib/components/fallbacks/not_found.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/extensions/context.dart'; class NotFound extends StatelessWidget { final bool vertical; @@ -18,9 +19,9 @@ class NotFound extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text("Nothing found", style: theme.textTheme.titleLarge), + Text(context.l10n.nothing_found, style: theme.textTheme.titleLarge), Text( - "The box is empty", + context.l10n.the_box_is_empty, style: theme.textTheme.titleMedium, ), ], diff --git a/lib/components/playbutton_card.dart b/lib/components/playbutton_card.dart index a0b96ab8..a1a9bfb4 100644 --- a/lib/components/playbutton_card.dart +++ b/lib/components/playbutton_card.dart @@ -3,11 +3,11 @@ 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/spotube_icons.dart'; import 'package:spotube/components/hover_builder.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/string.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; @@ -128,7 +128,7 @@ class PlaybuttonCard extends HookWidget { ), if (isHovered) Text( - "Owned by you", + context.l10n.owned_by_you, style: theme.textTheme.bodySmall?.copyWith( color: Colors.white, ), diff --git a/lib/components/tracks_view/sections/header/header_actions.dart b/lib/components/tracks_view/sections/header/header_actions.dart index 94f0baa2..8e378f97 100644 --- a/lib/components/tracks_view/sections/header/header_actions.dart +++ b/lib/components/tracks_view/sections/header/header_actions.dart @@ -32,6 +32,9 @@ class TrackViewHeaderActions extends HookConsumerWidget { final auth = ref.watch(authenticationProvider); + final copiedText = + context.l10n.copied_shareurl_to_clipboard(props.shareUrl); + return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -48,7 +51,7 @@ class TrackViewHeaderActions extends HookConsumerWidget { width: 300, behavior: SnackBarBehavior.floating, content: Text( - "Copied ${props.shareUrl} to clipboard", + copiedText, textAlign: TextAlign.center, ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index ab615225..63c805f4 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -326,5 +326,64 @@ "this_device": "This Device", "remote": "Remote", "stats": "Stats", - "and_n_more": "and {count} more" + "and_n_more": "and {count} more", + "recently_played": "Recently Played", + "browse_more": "Browse More", + "no_title": "No Title", + "not_playing": "Not playing", + "epic_failure": "Epic failure!", + "added_num_tracks_to_queue": "Added {tracks_length} tracks to queue", + "spotube_has_an_update": "Spotube has an update", + "download_now": "Download Now", + "nightly_version": "Spotube Nightly {nightlyBuildNum} has been released", + "release_version": "Spotube v{version} has been released", + "read_the_latest": "Read the latest ", + "release_notes": "release notes", + "pick_color_scheme": "Pick color scheme", + "save": "Save", + "choose_the_device": "Choose the device:", + "multiple_device_connected": "There are multiple device connected.\nChoose the device you want this action to take place", + "nothing_found": "Nothing found", + "the_box_is_empty": "The box is empty", + "top_tracks": "Top Tracks", + "top_artists": "Top Artists", + "top_albums": "Top Albums", + "this_week": "This week", + "this_month": "This month", + "last_6_months": "Last 6 months", + "this_year": "This year", + "last_2_years": "Last 2 years", + "all_time": "All time", + "powered_by_provider": "Powered by {providerName}", + "email": "Email", + "profile_followers": "Followers", + "birthday": "Birthday", + "country": "Country", + "subscription": "Subscription", + "not_born": "Not born", + "hacker": "Hacker", + "profile": "Profile", + "no_name": "No Name", + "edit": "Edit", + "user_profile": "User Profile", + "count_plays": "{count} plays", + "streaming_fees_hypothetical": "Streaming fees (hypothetical)", + "minutes_listened": "Minutes listened", + "streamed_songs": "Streamed songs", + "count_streams": "{count} streams", + "owned_by_you": "Owned by you", + "copied_shareurl_to_clipboard": "Copied {shareUrl} to clipboard", + "spotify_hipotetical_calculation": "*This is calculated based on Spotify's per stream\npayout of $0.003 to $0.005. This is a hypothetical\ncalculation to give user insight about how much they\nwould have paid to the artists if they were to listen\ntheir song in Spotify.", + "count_mins": "{minutes} mins", + "summary_minutes": "minutes", + "summary_listened_to_music": "Listened to music", + "summary_songs": "songs", + "summary_streamed_overall": "Streamed overall", + "summary_owed_to_artists": "Owed to artists\nthis month", + "summary_artists": "artist's", + "summary_music_reached_you": "Music reached you", + "summary_full_albums": "full albums", + "summary_got_your_love": "Got your love", + "summary_playlists": "playlists", + "summary_were_on_repeat": "Were on repeat" } \ No newline at end of file diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb index fb00a925..8f041581 100644 --- a/lib/l10n/app_eu.arb +++ b/lib/l10n/app_eu.arb @@ -107,6 +107,9 @@ "always_on_top": "Beti ikusgai", "exit_mini_player": "Irten mini erreproduzitzailetik", "download_location": "Deskargen kokapena", + "local_library": "Liburutegi lokala", + "add_library_location": "Gehitu liburutegira", + "remove_library_location": "Kendu liburutegitik", "account": "Kontua", "login_with_spotify": "Hasi saioa zure Spotify kontuarekin", "connect_with_spotify": "Spotify-rekin konektatu", @@ -118,8 +121,8 @@ "market_place_region": "Dendaren herrialdea", "recommendation_country": "Gomendio herrialdea", "appearance": "Itxura", - "layout_mode": "Diseinu modua", - "override_layout_settings": "Responsive diseinu moduaren ezarpenak ezeztatu", + "layout_mode": "Diseinua", + "override_layout_settings": "Responsive diseinuaren ezarpenak ezeztatu", "adaptive": "Moldagarria", "compact": "Trinkoa", "extended": "Hedatua", @@ -287,7 +290,7 @@ "genres": "Generoak", "explore_genres": "Esploratu generoak", "friends": "Lagunak", - "no_lyrics_available": "Sentitzen dut, ezin dira kanta honen hitzak aurkitu", + "no_lyrics_available": "Sentitzen dugu, ezin dira kanta honen hitzak aurkitu", "start_a_radio": "Hasi Irrati bat", "how_to_start_radio": "Nola hasi nahi duzu irratia?", "replace_queue_question": "Uneko zerrenda ordezkatu nahi duzu edo bertan gehitu?", @@ -295,6 +298,7 @@ "delete_playlist": "Ezabatu zerrenda", "delete_playlist_confirmation": "Ziur zaude zerrenda ezabatu nahi duzula?", "local_tracks": "Kanta lokalak", + "local_tab": "Lokalean", "song_link": "Kantaren lotura", "skip_this_nonsense": "Utzi txorakeria hau", "freedom_of_music": "“Musika Askatasuna”", @@ -321,9 +325,65 @@ "connect_client_alert": "{client} gailuak kontrolatzen zaitu", "this_device": "Gailu hau", "remote": "Urrunekoa", - "local_library": "Liburutegi lokala", - "add_library_location": "Gehitu liburutegira", - "remove_library_location": "Kendu liburutegitik", - "local_tab": "Tokiko", - "stats": "Estatistikak" + "stats": "Estatistikak", + "and_n_more": "eta {count} gehiago", + "recently_played": "Berriki entzunak", + "browse_more": "Gehiago Bilatu", + "no_title": "Titulurik ez", + "not_playing": "Erreprodukziorik ez", + "epic_failure": "Sekulako errorea!", + "added_num_tracks_to_queue": "{tracks_length} kanta gehitu dira zerrendara", + "spotube_has_an_update": "Spotube-ren eguneraketa bat dago", + "download_now": "Orain deskargatu", + "nightly_version": "Spotube {nightlyBuildNum} Nightly-a argitaratu da", + "release_version": "Spotube v{version} argitaratu da", + "read_the_latest": "Irakurri azken ", + "release_notes": "argitatratze oharrak", + "pick_color_scheme": "Aukeratu kolore eskema", + "save": "Gorde", + "choose_the_device": "Aukeratu gailua:", + "multiple_device_connected": "Hainbat gailu daude konektatuta.\nAukeratu zein gailutan aplikatu nahi duzun ekintza hau", + "nothing_found": "Ezer ez da aurkitu", + "the_box_is_empty": "Kaxa hutsik dago", + "top_tracks": "Top Kantak", + "top_artists": "Top Artistak", + "top_albums": "Top Albumak", + "this_week": "Aste honetan", + "this_month": "Hilabete honetan", + "last_6_months": "Azken 6 hilabeteetan", + "this_year": "Aurten", + "last_2_years": "Azken 2 urtetan", + "all_time": "Betidanik", + "powered_by_provider": "{providerName}-ren eskutik", + "email": "Email", + "profile_followers": "Jarraitzaileak", + "birthday": "Jaiotze-data", + "country": "Herrialdea", + "subscription": "Harpidetzak", + "not_born": "Jaio gabe", + "hacker": "Hacker", + "profile": "Profila", + "no_name": "Izenik Ez", + "edit": "Editatu", + "user_profile": "Erabiltzaile Profila", + "count_plays": "{count} erreprodukzio", + "streaming_fees_hypothetical": "Streaming ordainketa (hipotetikoa)", + "minutes_listened": "Entzundako minutuak", + "streamed_songs": "Stream-eatutako kantak", + "count_streams": "{count} stream", + "owned_by_you": "Zure jabetzakoa", + "copied_shareurl_to_clipboard": "{shareUrl} arbelera kopiatua", + "spotify_hipotetical_calculation": "*Sportify-k stream bakoitzeko duen $0.003 eta $0.005\nordainsarian oinarritua da. Kalkulu hipotetiko bat,\nkanta hauek Spotify-n entzun bazenitu,\nberaiek artistari zenbat ordaiduko lioketen jakin dezazun.", + "count_mins": "{minutes} minutu", + "summary_minutes": "minutu", + "summary_listened_to_music": "Musika entzuten", + "summary_songs": "kanta", + "summary_streamed_overall": "Stream-eatuta oro har", + "summary_owed_to_artists": "Hilabete honetan\nartistei zor zaiena", + "summary_artists": "artisten", + "summary_music_reached_you": "Musika ailegatu zaizu", + "summary_full_albums": "album osok", + "summary_got_your_love": "Izan dute zure maitasuna", + "summary_playlists": "zerrenda", + "summary_were_on_repeat": "Dituzu errepikatze moduan" } \ No newline at end of file diff --git a/lib/modules/home/sections/feed.dart b/lib/modules/home/sections/feed.dart index f66f01f2..8685fe19 100644 --- a/lib/modules/home/sections/feed.dart +++ b/lib/modules/home/sections/feed.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/home/feed/feed_section.dart'; import 'package:spotube/provider/spotify/views/home.dart'; import 'package:spotube/utils/service_utils.dart'; @@ -33,14 +34,14 @@ class HomePageFeedSection extends HookConsumerWidget { else if (item.playlist != null) item.playlist!.asPlaylist ], - title: Text(section.title ?? "No Titel"), + title: Text(section.title ?? context.l10n.no_title), hasNextPage: false, isLoadingNextPage: false, onFetchMore: () {}, titleTrailing: Directionality( textDirection: TextDirection.rtl, child: TextButton.icon( - label: const Text("Browse More"), + label: Text(context.l10n.browse_more), icon: const Icon(SpotubeIcons.angleRight), onPressed: () => ServiceUtils.pushNamed( context, diff --git a/lib/modules/home/sections/friends.dart b/lib/modules/home/sections/friends.dart index d6bed6a8..6f59c209 100644 --- a/lib/modules/home/sections/friends.dart +++ b/lib/modules/home/sections/friends.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/modules/home/sections/friends/friend_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_breakpoint_value.dart'; import 'package:spotube/models/spotify_friends.dart'; import 'package:spotube/provider/authentication/authentication.dart'; @@ -73,7 +74,7 @@ class HomePageFriendsSection extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.all(8.0), child: Text( - 'Friends', + context.l10n.friends, style: Theme.of(context).textTheme.titleMedium, ), ), diff --git a/lib/modules/home/sections/recent.dart b/lib/modules/home/sections/recent.dart index b26c0e16..43c0459d 100644 --- a/lib/modules/home/sections/recent.dart +++ b/lib/modules/home/sections/recent.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/history/recent.dart'; @@ -22,7 +23,7 @@ class HomeRecentlyPlayedSection extends HookConsumerWidget { return Skeletonizer( enabled: history.isLoading, child: HorizontalPlaybuttonCardView( - title: const Text('Recently Played'), + title: Text(context.l10n.recently_played), items: [ for (final item in historyData) if (item.playlist != null) diff --git a/lib/modules/library/playlist_generate/multi_select_field.dart b/lib/modules/library/playlist_generate/multi_select_field.dart index e54fc2ba..8cafe02f 100644 --- a/lib/modules/library/playlist_generate/multi_select_field.dart +++ b/lib/modules/library/playlist_generate/multi_select_field.dart @@ -187,7 +187,7 @@ class _MultiSelectDialog extends HookWidget { return AlertDialog( scrollable: true, - title: dialogTitle ?? const Text('Select'), + title: dialogTitle ?? Text(context.l10n.select), contentPadding: mediaQuery.mdAndUp ? null : const EdgeInsets.all(16), insetPadding: const EdgeInsets.all(16), actions: [ diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index 6db84692..538af685 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -233,7 +233,8 @@ class PlayerView extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ AutoSizeText( - currentTrack?.name ?? "Not playing", + currentTrack?.name ?? + context.l10n.not_playing, style: TextStyle( color: titleTextColor, fontSize: 22, diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index d6ea2a46..df683a80 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; import 'package:spotube/components/playbutton_card.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/pages/playlist/playlist.dart'; @@ -133,8 +134,8 @@ class PlaylistCard extends HookConsumerWidget { historyNotifier.addPlaylists([playlist]); if (context.mounted) { final snackbar = SnackBar( - content: - Text("Added ${fetchedInitialTracks.length} tracks to queue"), + content: Text(context.l10n + .added_num_tracks_to_queue(fetchedInitialTracks.length)), action: SnackBarAction( label: "Undo", onPressed: () { diff --git a/lib/modules/playlist/playlist_create_dialog.dart b/lib/modules/playlist/playlist_create_dialog.dart index b9e4be8f..78680a1c 100644 --- a/lib/modules/playlist/playlist_create_dialog.dart +++ b/lib/modules/playlist/playlist_create_dialog.dart @@ -76,7 +76,7 @@ class PlaylistCreateDialog extends HookConsumerWidget { scaffold.showSnackBar( SnackBar( content: Text( - l10n.error(error.message ?? "Epic failure!"), + l10n.error(error.message ?? context.l10n.epic_failure), style: theme.textTheme.bodyMedium!.copyWith( color: theme.colorScheme.onError, ), diff --git a/lib/modules/root/update_dialog.dart b/lib/modules/root/update_dialog.dart index 4a313096..27b857df 100644 --- a/lib/modules/root/update_dialog.dart +++ b/lib/modules/root/update_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:spotube/components/links/anchor_button.dart'; import 'package:url_launcher/url_launcher_string.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:version/version.dart'; class RootAppUpdateDialog extends StatelessWidget { @@ -16,10 +17,10 @@ class RootAppUpdateDialog extends StatelessWidget { const url = "https://spotube.krtirtho.dev/downloads"; const nightlyUrl = "https://spotube.krtirtho.dev/downloads/nightly"; return AlertDialog( - title: const Text("Spotube has an update"), + title: Text(context.l10n.spotube_has_an_update), actions: [ FilledButton( - child: const Text("Download Now"), + child: Text(context.l10n.download_now), onPressed: () => launchUrlString( nightlyBuildNum != null ? nightlyUrl : url, mode: LaunchMode.externalApplication, @@ -31,16 +32,16 @@ class RootAppUpdateDialog extends StatelessWidget { children: [ Text( nightlyBuildNum != null - ? "Spotube Nightly $nightlyBuildNum has been released" - : "Spotube v$version has been released", + ? context.l10n.nightly_version(nightlyBuildNum!) + : context.l10n.release_version(version!), ), if (nightlyBuildNum == null) Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Text("Read the latest "), + Text(context.l10n.read_the_latest), AnchorButton( - "release notes", + context.l10n.release_notes, style: const TextStyle(color: Colors.blue), onTap: () => launchUrlString( url, diff --git a/lib/modules/settings/color_scheme_picker_dialog.dart b/lib/modules/settings/color_scheme_picker_dialog.dart index 8d098375..550446bc 100644 --- a/lib/modules/settings/color_scheme_picker_dialog.dart +++ b/lib/modules/settings/color_scheme_picker_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:system_theme/system_theme.dart'; @@ -69,17 +70,17 @@ class ColorSchemePickerDialog extends HookConsumerWidget { } return AlertDialog( - title: const Text("Pick color scheme"), + title: Text(context.l10n.pick_color_scheme), actions: [ OutlinedButton( - child: const Text("Cancel"), + child: Text(context.l10n.cancel), onPressed: () { Navigator.pop(context); }, ), FilledButton( onPressed: onOk, - child: const Text("Save"), + child: Text(context.l10n.save), ), ], content: SizedBox( diff --git a/lib/modules/stats/summary/summary.dart b/lib/modules/stats/summary/summary.dart index ef8aa1b0..46068fec 100644 --- a/lib/modules/stats/summary/summary.dart +++ b/lib/modules/stats/summary/summary.dart @@ -5,6 +5,7 @@ import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/summary/summary_card.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/stats/albums/albums.dart'; import 'package:spotube/pages/stats/artists/artists.dart'; import 'package:spotube/pages/stats/fees/fees.dart'; @@ -45,8 +46,8 @@ class StatsPageSummarySection extends HookConsumerWidget { delegate: SliverChildListDelegate([ SummaryCard( title: summaryData.duration.inMinutes.toDouble(), - unit: "minutes", - description: 'Listened to music', + unit: context.l10n.summary_minutes, + description: context.l10n.summary_listened_to_music, color: Colors.purple, onTap: () { ServiceUtils.pushNamed(context, StatsMinutesPage.name); @@ -54,8 +55,8 @@ class StatsPageSummarySection extends HookConsumerWidget { ), SummaryCard( title: summaryData.tracks.toDouble(), - unit: "songs", - description: 'Streamed overall', + unit: context.l10n.summary_songs, + description: context.l10n.summary_streamed_overall, color: Colors.lightBlue, onTap: () { ServiceUtils.pushNamed(context, StatsStreamsPage.name); @@ -64,7 +65,7 @@ class StatsPageSummarySection extends HookConsumerWidget { SummaryCard.unformatted( title: usdFormatter.format(summaryData.fees.toDouble()), unit: "", - description: 'Owed to artists\nthis month', + description: context.l10n.summary_owed_to_artists, color: Colors.green, onTap: () { ServiceUtils.pushNamed(context, StatsStreamFeesPage.name); @@ -72,8 +73,8 @@ class StatsPageSummarySection extends HookConsumerWidget { ), SummaryCard( title: summaryData.artists.toDouble(), - unit: "artist's", - description: 'Music reached you', + unit: context.l10n.summary_artists, + description: context.l10n.summary_music_reached_you, color: Colors.yellow, onTap: () { ServiceUtils.pushNamed(context, StatsArtistsPage.name); @@ -81,8 +82,8 @@ class StatsPageSummarySection extends HookConsumerWidget { ), SummaryCard( title: summaryData.albums.toDouble(), - unit: "full albums", - description: 'Got your love', + unit: context.l10n.summary_full_albums, + description: context.l10n.summary_got_your_love, color: Colors.pink, onTap: () { ServiceUtils.pushNamed(context, StatsAlbumsPage.name); @@ -90,8 +91,8 @@ class StatsPageSummarySection extends HookConsumerWidget { ), SummaryCard( title: summaryData.playlists.toDouble(), - unit: "playlists", - description: 'Were on repeat', + unit: context.l10n.summary_playlists, + description: context.l10n.summary_were_on_repeat, color: Colors.teal, onTap: () { ServiceUtils.pushNamed(context, StatsPlaylistsPage.name); diff --git a/lib/modules/stats/top/albums.dart b/lib/modules/stats/top/albums.dart index 4329b871..e401340e 100644 --- a/lib/modules/stats/top/albums.dart +++ b/lib/modules/stats/top/albums.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/album_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/albums.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -35,7 +36,8 @@ class TopAlbums extends HookConsumerWidget { return StatsAlbumItem( album: album.album, info: Text( - "${compactNumberFormatter.format(album.count)} plays", + context.l10n + .count_plays(compactNumberFormatter.format(album.count)), ), ); }, diff --git a/lib/modules/stats/top/artists.dart b/lib/modules/stats/top/artists.dart index d5eb2d0e..3e4e098d 100644 --- a/lib/modules/stats/top/artists.dart +++ b/lib/modules/stats/top/artists.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -38,7 +39,10 @@ class TopArtists extends HookConsumerWidget { final artist = artistsData[index]; return StatsArtistItem( artist: artist.artist, - info: Text("${compactNumberFormatter.format(artist.count)} plays"), + info: Text( + context.l10n + .count_plays(compactNumberFormatter.format(artist.count)), + ), ); }, ), diff --git a/lib/modules/stats/top/top.dart b/lib/modules/stats/top/top.dart index ea52c517..643064aa 100644 --- a/lib/modules/stats/top/top.dart +++ b/lib/modules/stats/top/top.dart @@ -5,6 +5,7 @@ import 'package:spotube/components/themed_button_tab_bar.dart'; import 'package:spotube/modules/stats/top/albums.dart'; import 'package:spotube/modules/stats/top/artists.dart'; import 'package:spotube/modules/stats/top/tracks.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/history/top.dart'; @@ -24,23 +25,23 @@ class StatsPageTopSection extends HookConsumerWidget { floating: true, flexibleSpace: ThemedButtonsTabBar( controller: tabController, - tabs: const [ + tabs: [ Tab( child: Padding( - padding: EdgeInsets.all(5), - child: Text("Top Tracks"), + padding: const EdgeInsets.all(5), + child: Text(context.l10n.top_tracks), ), ), Tab( child: Padding( - padding: EdgeInsets.all(5), - child: Text("Top Artists"), + padding: const EdgeInsets.all(5), + child: Text(context.l10n.top_artists), ), ), Tab( child: Padding( - padding: EdgeInsets.all(5), - child: Text("Top Albums"), + padding: const EdgeInsets.all(5), + child: Text(context.l10n.top_albums), ), ), ], @@ -61,30 +62,30 @@ class StatsPageTopSection extends HookConsumerWidget { historyDurationNotifier.update((_) => value); }, icon: const Icon(Icons.arrow_drop_down), - items: const [ + items: [ DropdownMenuItem( value: HistoryDuration.days7, - child: Text("This week"), + child: Text(context.l10n.this_week), ), DropdownMenuItem( value: HistoryDuration.days30, - child: Text("This month"), + child: Text(context.l10n.this_month), ), DropdownMenuItem( value: HistoryDuration.months6, - child: Text("Last 6 months"), + child: Text(context.l10n.last_6_months), ), DropdownMenuItem( value: HistoryDuration.year, - child: Text("This year"), + child: Text(context.l10n.this_year), ), DropdownMenuItem( value: HistoryDuration.years2, - child: Text("Last 2 years"), + child: Text(context.l10n.last_2_years), ), DropdownMenuItem( value: HistoryDuration.allTime, - child: Text("All time"), + child: Text(context.l10n.all_time), ), ], ), diff --git a/lib/modules/stats/top/tracks.dart b/lib/modules/stats/top/tracks.dart index be457b2e..7fba220d 100644 --- a/lib/modules/stats/top/tracks.dart +++ b/lib/modules/stats/top/tracks.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/tracks.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -37,7 +38,8 @@ class TopTracks extends HookConsumerWidget { return StatsTrackItem( track: track.track, info: Text( - "${compactNumberFormatter.format(track.count)} plays", + context.l10n + .count_plays(compactNumberFormatter.format(track.count)), ), ); }, diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 18ce6e28..810c18d6 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -73,7 +73,7 @@ class LyricsPage extends HookConsumerWidget { return Align( alignment: Alignment.bottomRight, - child: Text("Powered by $providerName"), + child: Text(context.l10n.powered_by_provider(providerName)), ); }, ), diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index c2bf7b81..643c1064 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -103,7 +103,7 @@ class SyncedLyrics extends HookConsumerWidget { backgroundColor: Colors.transparent, centerTitle: true, title: Text( - playlist.activeTrack?.name ?? "Not Playing", + playlist.activeTrack?.name ?? context.l10n.not_playing, style: headlineTextStyle, ), bottom: PreferredSize( diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart index e6546960..67bd8f57 100644 --- a/lib/pages/profile/profile.dart +++ b/lib/pages/profile/profile.dart @@ -9,6 +9,7 @@ import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -27,21 +28,22 @@ class ProfilePage extends HookConsumerWidget { final userProperties = useMemoized( () => { - "Email": meData.email ?? "N/A", - "Followers": meData.followers?.total.toString() ?? "N/A", - "Birthday": meData.birthdate ?? "Not born", - "Country": spotifyMarkets + context.l10n.email: meData.email ?? "N/A", + context.l10n.profile_followers: + meData.followers?.total.toString() ?? "N/A", + context.l10n.birthday: meData.birthdate ?? context.l10n.not_born, + context.l10n.country: spotifyMarkets .firstWhere((market) => market.$1 == meData.country) .$2, - "Subscription": meData.product ?? "Hacker", + context.l10n.subscription: meData.product ?? context.l10n.hacker, }, [meData], ); return SafeArea( child: Scaffold( - appBar: const PageWindowTitleBar( - title: Text("Profile"), + appBar: PageWindowTitleBar( + title: Text(context.l10n.profile), titleSpacing: 0, automaticallyImplyLeading: true, centerTitle: false, @@ -72,7 +74,7 @@ class ProfilePage extends HookConsumerWidget { const SliverGap(10), SliverToBoxAdapter( child: Text( - meData.displayName ?? "No Name", + meData.displayName ?? context.l10n.no_name, style: textTheme.titleLarge, textAlign: TextAlign.center, ), @@ -85,7 +87,7 @@ class ProfilePage extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton.icon( - label: const Text("Edit"), + label: Text(context.l10n.edit), icon: const Icon(SpotubeIcons.edit), onPressed: () { launchUrlString( @@ -118,7 +120,7 @@ class ProfilePage extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.all(6), child: Text( - key, + '$key', style: textTheme.titleSmall, ), ), diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 7e37b68b..1b5b7e39 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -92,7 +92,7 @@ class SettingsAccountSection extends HookConsumerWidget { if (auth.asData?.value != null) ListTile( leading: const Icon(SpotubeIcons.user), - title: const Text("User Profile"), + title: Text(context.l10n.user_profile), trailing: Padding( padding: const EdgeInsets.all(8.0), child: CircleAvatar( diff --git a/lib/pages/stats/albums/albums.dart b/lib/pages/stats/albums/albums.dart index db0eedf6..e14a2f32 100644 --- a/lib/pages/stats/albums/albums.dart +++ b/lib/pages/stats/albums/albums.dart @@ -4,6 +4,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/album_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/albums.dart'; @@ -24,10 +25,10 @@ class StatsAlbumsPage extends HookConsumerWidget { final albumsData = topAlbums.asData?.value.items ?? []; return Scaffold( - appBar: const PageWindowTitleBar( + appBar: PageWindowTitleBar( automaticallyImplyLeading: true, centerTitle: false, - title: Text("Albums"), + title: Text(context.l10n.albums), ), body: Skeletonizer( enabled: topAlbums.isLoading && !topAlbums.isLoadingNextPage, @@ -43,9 +44,8 @@ class StatsAlbumsPage extends HookConsumerWidget { final album = albumsData[index]; return StatsAlbumItem( album: album.album, - info: Text( - "${compactNumberFormatter.format(album.count)} plays", - ), + info: Text(context.l10n + .count_plays(compactNumberFormatter.format(album.count))), ); }, ), diff --git a/lib/pages/stats/artists/artists.dart b/lib/pages/stats/artists/artists.dart index 80ff5f23..436bbb57 100644 --- a/lib/pages/stats/artists/artists.dart +++ b/lib/pages/stats/artists/artists.dart @@ -5,6 +5,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/tracks.dart'; @@ -27,10 +28,10 @@ class StatsArtistsPage extends HookConsumerWidget { () => topTracks.asData?.value.artists ?? [], [topTracks.asData?.value]); return Scaffold( - appBar: const PageWindowTitleBar( + appBar: PageWindowTitleBar( automaticallyImplyLeading: true, centerTitle: false, - title: Text("Artists"), + title: Text(context.l10n.artists), ), body: Skeletonizer( enabled: topTracks.isLoading && !topTracks.isLoadingNextPage, @@ -46,8 +47,8 @@ class StatsArtistsPage extends HookConsumerWidget { final artist = artistsData[index]; return StatsArtistItem( artist: artist.artist, - info: - Text("${compactNumberFormatter.format(artist.count)} plays"), + info: Text(context.l10n + .count_plays(compactNumberFormatter.format(artist.count))), ); }, ), diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index 33d223ae..5f9aa779 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -6,6 +6,7 @@ import 'package:sliver_tools/sliver_tools.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/artist_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/tracks.dart'; @@ -40,10 +41,10 @@ class StatsStreamFeesPage extends HookConsumerWidget { ); return Scaffold( - appBar: const PageWindowTitleBar( + appBar: PageWindowTitleBar( automaticallyImplyLeading: true, centerTitle: false, - title: Text("Streaming fees (hypothetical)"), + title: Text(context.l10n.streaming_fees_hypothetical), ), body: CustomScrollView( slivers: [ @@ -54,11 +55,7 @@ class StatsStreamFeesPage extends HookConsumerWidget { padding: const EdgeInsets.all(16.0), sliver: SliverToBoxAdapter( child: Text( - "*This is calculated based on Spotify's per stream " - "payout of \$0.003 to \$0.005. This is a hypothetical " - "calculation to give user insight about how much they " - "would have paid to the artists if they were to listen " - "their song in Spotify.", + context.l10n.spotify_hipotetical_calculation, style: textTheme.bodySmall?.copyWith( color: hintColor, ), diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index ea3048ef..35bea3ab 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -5,6 +5,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/tracks.dart'; @@ -27,8 +28,8 @@ class StatsMinutesPage extends HookConsumerWidget { final tracksData = topTracks.asData?.value.items ?? []; return Scaffold( - appBar: const PageWindowTitleBar( - title: Text("Minutes listened"), + appBar: PageWindowTitleBar( + title: Text(context.l10n.minutes_listened), centerTitle: false, automaticallyImplyLeading: true, ), @@ -48,7 +49,8 @@ class StatsMinutesPage extends HookConsumerWidget { return StatsTrackItem( track: track.track, info: Text( - "${compactNumberFormatter.format(track.count)} plays", + context.l10n + .count_plays(compactNumberFormatter.format(track.count)), ), ); }, diff --git a/lib/pages/stats/playlists/playlists.dart b/lib/pages/stats/playlists/playlists.dart index a6db3e1c..4e83b0a2 100644 --- a/lib/pages/stats/playlists/playlists.dart +++ b/lib/pages/stats/playlists/playlists.dart @@ -4,6 +4,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/playlist_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/playlists.dart'; @@ -25,10 +26,10 @@ class StatsPlaylistsPage extends HookConsumerWidget { final playlistsData = topPlaylists.asData?.value.items ?? []; return Scaffold( - appBar: const PageWindowTitleBar( + appBar: PageWindowTitleBar( automaticallyImplyLeading: true, centerTitle: false, - title: Text("Playlists"), + title: Text(context.l10n.playlists), ), body: Skeletonizer( enabled: topPlaylists.isLoading && !topPlaylists.isLoadingNextPage, @@ -45,7 +46,9 @@ class StatsPlaylistsPage extends HookConsumerWidget { return StatsPlaylistItem( playlist: playlist.playlist, info: Text( - "${compactNumberFormatter.format(playlist.count)} plays"), + context.l10n + .count_plays(compactNumberFormatter.format(playlist.count)), + ), ); }, ), diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index dd5856d0..5c90e879 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -5,6 +5,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/formatters.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/stats/common/track_item.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/history/top.dart'; import 'package:spotube/provider/history/top/tracks.dart'; @@ -27,8 +28,8 @@ class StatsStreamsPage extends HookConsumerWidget { final tracksData = topTracks.asData?.value.items ?? []; return Scaffold( - appBar: const PageWindowTitleBar( - title: Text("Streamed songs"), + appBar: PageWindowTitleBar( + title: Text(context.l10n.streamed_songs), centerTitle: false, automaticallyImplyLeading: true, ), @@ -48,7 +49,8 @@ class StatsStreamsPage extends HookConsumerWidget { return StatsTrackItem( track: track.track, info: Text( - "${compactNumberFormatter.format(track.count * track.track.duration!.inMinutes)} mins", + context.l10n.count_mins(compactNumberFormatter + .format(track.count * track.track.duration!.inMinutes)), ), ); }, From 9b7a7ef1cfe119e6c94ed4a1774a29c148544e79 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 10 Aug 2024 22:54:25 +0600 Subject: [PATCH 208/261] chore: update translations and refactor to flutter 3.22 ThemeData --- .../adaptive/adaptive_pop_sheet_list.dart | 2 +- .../fallbacks/anonymous_fallback.dart | 8 +- lib/components/links/anchor_button.dart | 2 +- lib/components/links/artist_link.dart | 1 - lib/components/playbutton_card.dart | 6 +- lib/components/themed_button_tab_bar.dart | 2 +- lib/components/titlebar/titlebar_buttons.dart | 12 +- lib/l10n/app_ar.arb | 61 +++++++++- lib/l10n/app_bn.arb | 61 +++++++++- lib/l10n/app_ca.arb | 61 +++++++++- lib/l10n/app_cs.arb | 61 +++++++++- lib/l10n/app_de.arb | 61 +++++++++- lib/l10n/app_en.arb | 5 +- lib/l10n/app_es.arb | 61 +++++++++- lib/l10n/app_eu.arb | 5 +- lib/l10n/app_fa.arb | 61 +++++++++- lib/l10n/app_fi.arb | 61 +++++++++- lib/l10n/app_fr.arb | 61 +++++++++- lib/l10n/app_hi.arb | 61 +++++++++- lib/l10n/app_id.arb | 61 +++++++++- lib/l10n/app_it.arb | 61 +++++++++- lib/l10n/app_ja.arb | 61 +++++++++- lib/l10n/app_ka.arb | 61 +++++++++- lib/l10n/app_ko.arb | 61 +++++++++- lib/l10n/app_ne.arb | 61 +++++++++- lib/l10n/app_nl.arb | 61 +++++++++- lib/l10n/app_pl.arb | 61 +++++++++- lib/l10n/app_pt.arb | 61 +++++++++- lib/l10n/app_ru.arb | 64 ++++++++++- lib/l10n/app_th.arb | 61 +++++++++- lib/l10n/app_tr.arb | 61 +++++++++- lib/l10n/app_uk.arb | 61 +++++++++- lib/l10n/app_vi.arb | 61 +++++++++- lib/l10n/app_zh.arb | 61 +++++++++- lib/modules/artist/artist_card.dart | 4 +- .../home/sections/friends/friend_item.dart | 2 +- lib/modules/home/sections/genres.dart | 2 +- .../local_folder/local_folder_item.dart | 2 +- .../playlist_generate/multi_select_field.dart | 2 +- lib/modules/player/player.dart | 2 +- lib/modules/player/player_queue.dart | 3 +- lib/modules/player/sibling_tracks_sheet.dart | 3 +- lib/modules/root/bottom_player.dart | 6 +- lib/modules/root/sidebar.dart | 2 +- lib/modules/root/spotube_navigation_bar.dart | 2 +- .../settings/color_scheme_picker_dialog.dart | 4 +- lib/pages/lyrics/lyrics.dart | 2 +- lib/pages/lyrics/mini_lyrics.dart | 14 +-- lib/pages/profile/profile.dart | 2 +- lib/pages/root/root_app.dart | 4 +- lib/pages/search/search.dart | 6 +- lib/pages/settings/sections/about.dart | 7 +- lib/pages/settings/sections/accounts.dart | 2 +- lib/pages/stats/fees/fees.dart | 16 +-- lib/provider/audio_player/audio_player.dart | 2 - .../audio_players_streams_mixin.dart | 7 +- lib/themes/theme.dart | 19 ++-- untranslated_messages.json | 106 +----------------- 58 files changed, 1579 insertions(+), 213 deletions(-) diff --git a/lib/components/adaptive/adaptive_pop_sheet_list.dart b/lib/components/adaptive/adaptive_pop_sheet_list.dart index 1686801c..97dc6132 100644 --- a/lib/components/adaptive/adaptive_pop_sheet_list.dart +++ b/lib/components/adaptive/adaptive_pop_sheet_list.dart @@ -187,7 +187,7 @@ class AdaptivePopSheetList extends StatelessWidget { icon: icon ?? const Icon(SpotubeIcons.moreVertical), tooltip: tooltip, style: theme.iconButtonTheme.style?.copyWith( - shape: MaterialStatePropertyAll( + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: borderRadius, ), diff --git a/lib/components/fallbacks/anonymous_fallback.dart b/lib/components/fallbacks/anonymous_fallback.dart index 799297e3..62ed8ddd 100644 --- a/lib/components/fallbacks/anonymous_fallback.dart +++ b/lib/components/fallbacks/anonymous_fallback.dart @@ -15,9 +15,13 @@ class AnonymousFallback extends ConsumerWidget { @override Widget build(BuildContext context, ref) { - final isLoggedIn = ref.watch(authenticationProvider) != null; + final isLoggedIn = ref.watch(authenticationProvider); - if (isLoggedIn && child != null) return child!; + if (isLoggedIn.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (isLoggedIn.asData?.value != null && child != null) return child!; return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/components/links/anchor_button.dart b/lib/components/links/anchor_button.dart index d78bbf96..c6f0b889 100644 --- a/lib/components/links/anchor_button.dart +++ b/lib/components/links/anchor_button.dart @@ -29,7 +29,7 @@ class AnchorButton extends HookWidget { onTapUp: (event) => tap.value = false, onTap: onTap, child: MouseRegion( - cursor: MaterialStateMouseCursor.clickable, + cursor: WidgetStateMouseCursor.clickable, child: Text( text, style: style.copyWith( diff --git a/lib/components/links/artist_link.dart b/lib/components/links/artist_link.dart index d5ec24f8..9f06f1b3 100644 --- a/lib/components/links/artist_link.dart +++ b/lib/components/links/artist_link.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/links/anchor_button.dart'; import 'package:spotube/extensions/context.dart'; diff --git a/lib/components/playbutton_card.dart b/lib/components/playbutton_card.dart index a1a9bfb4..d540d31e 100644 --- a/lib/components/playbutton_card.dart +++ b/lib/components/playbutton_card.dart @@ -64,12 +64,12 @@ class PlaybuttonCard extends HookWidget { margin: margin, child: Material( color: Color.lerp( - theme.colorScheme.surfaceVariant, + theme.colorScheme.surfaceContainerHighest, theme.colorScheme.surface, useBrightnessValue(.9, .7), ), borderRadius: radius, - shadowColor: theme.colorScheme.background, + shadowColor: theme.colorScheme.surface, elevation: 3, child: InkWell( mouseCursor: SystemMouseCursors.click, @@ -149,7 +149,7 @@ class PlaybuttonCard extends HookWidget { Skeleton.keep( child: IconButton( style: IconButton.styleFrom( - backgroundColor: theme.colorScheme.background, + backgroundColor: theme.colorScheme.surface, foregroundColor: theme.colorScheme.primary, minimumSize: const Size.square(10), ), diff --git a/lib/components/themed_button_tab_bar.dart b/lib/components/themed_button_tab_bar.dart index b21ca992..c245e5f4 100644 --- a/lib/components/themed_button_tab_bar.dart +++ b/lib/components/themed_button_tab_bar.dart @@ -34,7 +34,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget { ), borderWidth: 0, unselectedDecoration: BoxDecoration( - color: theme.colorScheme.background, + color: theme.colorScheme.surface, borderRadius: BorderRadius.circular(15), ), unselectedLabelStyle: theme.textTheme.labelLarge?.copyWith( diff --git a/lib/components/titlebar/titlebar_buttons.dart b/lib/components/titlebar/titlebar_buttons.dart index 425bf2f1..35cdf08e 100644 --- a/lib/components/titlebar/titlebar_buttons.dart +++ b/lib/components/titlebar/titlebar_buttons.dart @@ -42,16 +42,16 @@ class WindowTitleBarButtons extends HookConsumerWidget { final theme = Theme.of(context); final colors = WindowButtonColors( normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onBackground, - mouseOver: theme.colorScheme.onBackground.withOpacity(0.1), - mouseDown: theme.colorScheme.onBackground.withOpacity(0.2), - iconMouseOver: theme.colorScheme.onBackground, - iconMouseDown: theme.colorScheme.onBackground, + iconNormal: foregroundColor ?? theme.colorScheme.onSurface, + mouseOver: theme.colorScheme.onSurface.withOpacity(0.1), + mouseDown: theme.colorScheme.onSurface.withOpacity(0.2), + iconMouseOver: theme.colorScheme.onSurface, + iconMouseDown: theme.colorScheme.onSurface, ); final closeColors = WindowButtonColors( normal: Colors.transparent, - iconNormal: foregroundColor ?? theme.colorScheme.onBackground, + iconNormal: foregroundColor ?? theme.colorScheme.onSurface, mouseOver: Colors.red, mouseDown: Colors.red[800]!, iconMouseOver: Colors.white, diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index b474ec7e..a962b41b 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -325,5 +325,64 @@ "add_library_location": "أضف إلى المكتبة", "remove_library_location": "إزالة من المكتبة", "local_tab": "محلي", - "stats": "إحصائيات" + "stats": "إحصائيات", + "and_n_more": "و {count} أكثر", + "recently_played": "تم تشغيله مؤخرًا", + "browse_more": "تصفح المزيد", + "no_title": "بدون عنوان", + "not_playing": "غير مشغل", + "epic_failure": "فشل كبير!", + "added_num_tracks_to_queue": "تمت إضافة {tracks_length} مسارات إلى قائمة الانتظار", + "spotube_has_an_update": "يوجد تحديث لسبوتيوب", + "download_now": "تحميل الآن", + "nightly_version": "تم إصدار سبوتيوب الليلي {nightlyBuildNum}", + "release_version": "تم إصدار سبوتيوب v{version}", + "read_the_latest": "اقرأ الأحدث", + "release_notes": "ملاحظات الإصدار", + "pick_color_scheme": "اختر نظام الألوان", + "save": "حفظ", + "choose_the_device": "اختر الجهاز:", + "multiple_device_connected": "تم توصيل أجهزة متعددة.\nاختر الجهاز الذي تريد إجراء هذه العملية عليه", + "nothing_found": "لم يتم العثور على شيء", + "the_box_is_empty": "الصندوق فارغ", + "top_artists": "أفضل الفنانين", + "top_albums": "أفضل الألبومات", + "this_week": "هذا الأسبوع", + "this_month": "هذا الشهر", + "last_6_months": "آخر 6 أشهر", + "this_year": "هذا العام", + "last_2_years": "آخر سنتين", + "all_time": "كل الوقت", + "powered_by_provider": "مدعوم من {providerName}", + "email": "البريد الإلكتروني", + "profile_followers": "المتابعين", + "birthday": "عيد الميلاد", + "subscription": "اشتراك", + "not_born": "لم يولد", + "hacker": "هاكر", + "profile": "الملف الشخصي", + "no_name": "بدون اسم", + "edit": "تعديل", + "user_profile": "ملف المستخدم", + "count_plays": "{count} تشغيلات", + "streaming_fees_hypothetical": "رسوم البث (افتراضية)", + "minutes_listened": "الدقائق المستمعة", + "streamed_songs": "الأغاني المذاعة", + "count_streams": "{count} بث", + "owned_by_you": "مملوك لك", + "copied_shareurl_to_clipboard": "تم نسخ {shareUrl} إلى الحافظة", + "spotify_hipotetical_calculation": "*هذا محسوب بناءً على الدفع لكل بث من سبوتيفاي\nبقيمة 0.003 إلى 0.005 دولار. هذا حساب افتراضي\nلإعطاء المستخدم فكرة عن المبلغ الذي\nكان سيدفعه للفنانين إذا كانوا قد استمعوا\nإلى أغنيتهم على سبوتيفاي.", + "count_mins": "{minutes} دقيقة", + "summary_minutes": "الدقائق", + "summary_listened_to_music": "استمعت إلى الموسيقى", + "summary_songs": "أغاني", + "summary_streamed_overall": "بث بشكل عام", + "summary_owed_to_artists": "مدين للفنانين\nهذا الشهر", + "summary_artists": "الفنانين", + "summary_music_reached_you": "وصلت إليك الموسيقى", + "summary_full_albums": "ألبومات كاملة", + "summary_got_your_love": "حصلت على حبك", + "summary_playlists": "قوائم التشغيل", + "summary_were_on_repeat": "كانت على التكرار", + "total_money": "المجموع {money}" } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 2cf8dd43..97872c8c 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -325,5 +325,64 @@ "add_library_location": "লাইব্রেরিতে যোগ করুন", "remove_library_location": "লাইব্রেরি থেকে সরান", "local_tab": "স্থানীয়", - "stats": "পরিসংখ্যান" + "stats": "পরিসংখ্যান", + "and_n_more": "এবং {count} আরও", + "recently_played": "সম্প্রতি বাজানো", + "browse_more": "আরও ব্রাউজ করুন", + "no_title": "কোনো শিরোনাম নেই", + "not_playing": "চালানো হচ্ছে না", + "epic_failure": "বিরাট ব্যর্থতা!", + "added_num_tracks_to_queue": "{tracks_length} ট্র্যাক সারিতে যোগ করা হয়েছে", + "spotube_has_an_update": "স্পটিউবে একটি আপডেট আছে", + "download_now": "এখনই ডাউনলোড করুন", + "nightly_version": "স্পটিউব নাইটলি {nightlyBuildNum} প্রকাশিত হয়েছে", + "release_version": "স্পটিউব v{version} প্রকাশিত হয়েছে", + "read_the_latest": "সর্বশেষ পড়ুন", + "release_notes": "রিলিজ নোট", + "pick_color_scheme": "রঙের থিম নির্বাচন করুন", + "save": "সংরক্ষণ করুন", + "choose_the_device": "ডিভাইস নির্বাচন করুন:", + "multiple_device_connected": "একাধিক ডিভাইস সংযুক্ত রয়েছে।\nযে ডিভাইসে আপনি এই ক্রিয়াটি চালাতে চান সেটি নির্বাচন করুন", + "nothing_found": "কিছুই পাওয়া যায়নি", + "the_box_is_empty": "বাক্সটি খালি", + "top_artists": "শীর্ষ শিল্পী", + "top_albums": "শীর্ষ অ্যালবাম", + "this_week": "এই সপ্তাহ", + "this_month": "এই মাস", + "last_6_months": "গত ৬ মাস", + "this_year": "এই বছর", + "last_2_years": "গত ২ বছর", + "all_time": "সব সময়", + "powered_by_provider": "{providerName} দ্বারা চালিত", + "email": "ইমেইল", + "profile_followers": "অনুসারী", + "birthday": "জন্মদিন", + "subscription": "সাবস্ক্রিপশন", + "not_born": "জন্মগ্রহণ করেনি", + "hacker": "হ্যাকার", + "profile": "প্রোফাইল", + "no_name": "কোন নাম নেই", + "edit": "সম্পাদনা করুন", + "user_profile": "ব্যবহারকারীর প্রোফাইল", + "count_plays": "{count} বার প্লে হয়েছে", + "streaming_fees_hypothetical": "স্ট্রিমিং ফি (ধারণাগত)", + "minutes_listened": "শুনেছেন মিনিট", + "streamed_songs": "স্ট্রিম করা গান", + "count_streams": "{count} বার স্ট্রিম", + "owned_by_you": "আপনার মালিকানাধীন", + "copied_shareurl_to_clipboard": "{shareUrl} ক্লিপবোর্ডে কপি করা হয়েছে", + "spotify_hipotetical_calculation": "*এটি স্পোটিফাইয়ের প্রতি স্ট্রিম\n$0.003 থেকে $0.005 পেআউটের ভিত্তিতে গণনা করা হয়েছে। এটি একটি ধারণাগত\nগণনা ব্যবহারকারীদেরকে জানাতে দেয় যে কত টাকা\nতারা শিল্পীদের দিতো যদি তারা স্পোটিফাইতে\nতাদের গান শুনতেন।", + "count_mins": "{minutes} মিনিট", + "summary_minutes": "মিনিট", + "summary_listened_to_music": "সঙ্গীত শুনেছেন", + "summary_songs": "গান", + "summary_streamed_overall": "মোট স্ট্রিম", + "summary_owed_to_artists": "এই মাসে\nশিল্পীদেরকে ঋণী", + "summary_artists": "শিল্পীর", + "summary_music_reached_you": "আপনার কাছে পৌঁছেছে সঙ্গীত", + "summary_full_albums": "সম্পূর্ণ অ্যালবাম", + "summary_got_your_love": "আপনার ভালোবাসা পেয়েছে", + "summary_playlists": "প্লেলিস্ট", + "summary_were_on_repeat": "পুনরাবৃত্তিতে ছিল", + "total_money": "মোট {money}" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index ca4b019a..2cda6e88 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -325,5 +325,64 @@ "add_library_location": "Afegeix a la biblioteca", "remove_library_location": "Elimina de la biblioteca", "local_tab": "Local", - "stats": "Estadístiques" + "stats": "Estadístiques", + "and_n_more": "i {count} més", + "recently_played": "Reproduït recentment", + "browse_more": "Navega més", + "no_title": "Sense títol", + "not_playing": "No s'està reproduint", + "epic_failure": "Fracàs èpic!", + "added_num_tracks_to_queue": "Afegit {tracks_length} pistes a la cua", + "spotube_has_an_update": "Spotube té una actualització", + "download_now": "Descarregar ara", + "nightly_version": "Spotube Nightly {nightlyBuildNum} ha estat publicat", + "release_version": "Spotube v{version} ha estat publicat", + "read_the_latest": "Llegeix el més recent", + "release_notes": "notes de la versió", + "pick_color_scheme": "Tria l'esquema de colors", + "save": "Desar", + "choose_the_device": "Tria el dispositiu:", + "multiple_device_connected": "Hi ha diversos dispositius connectats.\nTria el dispositiu on vols realitzar aquesta acció", + "nothing_found": "No s'ha trobat res", + "the_box_is_empty": "La caixa està buida", + "top_artists": "Millors artistes", + "top_albums": "Millors àlbums", + "this_week": "Aquesta setmana", + "this_month": "Aquest mes", + "last_6_months": "Últims 6 mesos", + "this_year": "Aquest any", + "last_2_years": "Últims 2 anys", + "all_time": "Tots els temps", + "powered_by_provider": "Funciona amb {providerName}", + "email": "Correu electrònic", + "profile_followers": "Seguidors", + "birthday": "Aniversari", + "subscription": "Subscripció", + "not_born": "No ha nascut", + "hacker": "Hacker", + "profile": "Perfil", + "no_name": "Sense nom", + "edit": "Editar", + "user_profile": "Perfil d'usuari", + "count_plays": "{count} reproduccions", + "streaming_fees_hypothetical": "Comissions de streaming (hipotètic)", + "minutes_listened": "minuts escoltats", + "streamed_songs": "cançons reproduïdes", + "count_streams": "{count} reproduccions", + "owned_by_you": "De la teva propietat", + "copied_shareurl_to_clipboard": "S'ha copiat {shareUrl} al porta-retalls", + "spotify_hipotetical_calculation": "*Això es calcula basant-se en els\npagaments per reproducció de Spotify de $0.003 a $0.005.\nAquest és un càlcul hipotètic per\ndonar als usuaris una idea de quant\nhaurien pagat als artistes si haguessin escoltat\nla seva cançó a Spotify.", + "count_mins": "{minutes} minuts", + "summary_minutes": "minuts", + "summary_listened_to_music": "has escoltat música", + "summary_songs": "cançons", + "summary_streamed_overall": "reproduït en general", + "summary_owed_to_artists": "degut als artistes\nAquest mes", + "summary_artists": "artistes", + "summary_music_reached_you": "La música t'ha arribat", + "summary_full_albums": "Àlbums complets", + "summary_got_your_love": "ha aconseguit el teu amor", + "summary_playlists": "llistes de reproducció", + "summary_were_on_repeat": "estaven en repetició", + "total_money": "total {money}" } \ No newline at end of file diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index 7191c108..b1a22ee2 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -325,5 +325,64 @@ "add_library_location": "Přidat do knihovny", "remove_library_location": "Odebrat z knihovny", "local_tab": "Místní", - "stats": "Statistiky" + "stats": "Statistiky", + "and_n_more": "a dalších {count}", + "recently_played": "Nedávno přehráno", + "browse_more": "Procházet více", + "no_title": "Bez názvu", + "not_playing": "Nepřehrává se", + "epic_failure": "Epické selhání!", + "added_num_tracks_to_queue": "Přidáno {tracks_length} skladeb do fronty", + "spotube_has_an_update": "Spotube má aktualizaci", + "download_now": "Stáhnout nyní", + "nightly_version": "Byla vydána noční verze Spotube {nightlyBuildNum}", + "release_version": "Byla vydána verze Spotube v{version}", + "read_the_latest": "Přečtěte si nejnovější ", + "release_notes": "poznámky k vydání", + "pick_color_scheme": "Vyberte barevné schéma", + "save": "Uložit", + "choose_the_device": "Vyberte zařízení:", + "multiple_device_connected": "Je připojeno více zařízení.\nVyberte zařízení, na kterém chcete provést tuto akci", + "nothing_found": "Nic nenalezeno", + "the_box_is_empty": "Krabice je prázdná", + "top_artists": "Nejlepší umělci", + "top_albums": "Nejlepší alba", + "this_week": "Tento týden", + "this_month": "Tento měsíc", + "last_6_months": "Posledních 6 měsíců", + "this_year": "Tento rok", + "last_2_years": "Poslední 2 roky", + "all_time": "Všechny časy", + "powered_by_provider": "Pohání {providerName}", + "email": "Email", + "profile_followers": "Sledující", + "birthday": "Narozeniny", + "subscription": "Předplatné", + "not_born": "Nenarozen", + "hacker": "Hacker", + "profile": "Profil", + "no_name": "Bez jména", + "edit": "Upravit", + "user_profile": "Uživatelský profil", + "count_plays": "{count} přehrání", + "streaming_fees_hypothetical": "Poplatky za streamování (hypotetické)", + "minutes_listened": "Poslouchané minuty", + "streamed_songs": "Streamované skladby", + "count_streams": "{count} streamů", + "owned_by_you": "Vlastněno vámi", + "copied_shareurl_to_clipboard": "Zkopírováno {shareUrl} do schránky", + "spotify_hipotetical_calculation": "*Toto je vypočítáno na základě výplaty\nza stream Spotify od $0.003 do $0.005.\nToto je hypotetický výpočet,\nabyste měli představu o tom, kolik\nbyste zaplatili umělcům,\npokud byste poslouchali jejich píseň na Spotify.", + "count_mins": "{minutes} minut", + "summary_minutes": "minuty", + "summary_listened_to_music": "Poslouchal(a) hudbu", + "summary_songs": "písně", + "summary_streamed_overall": "Streamováno celkově", + "summary_owed_to_artists": "Dluženo umělcům\nTento měsíc", + "summary_artists": "umělců", + "summary_music_reached_you": "Hudba vás oslovila", + "summary_full_albums": "plná alba", + "summary_got_your_love": "Získal vaši lásku", + "summary_playlists": "playlisty", + "summary_were_on_repeat": "Byly na opakování", + "total_money": "Celkem {money}" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c455e08a..4b9495aa 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -325,5 +325,64 @@ "add_library_location": "Zur Bibliothek hinzufügen", "remove_library_location": "Aus der Bibliothek entfernen", "local_tab": "Lokal", - "stats": "Statistiken" + "stats": "Statistiken", + "and_n_more": "und {count} mehr", + "recently_played": "Zuletzt gespielt", + "browse_more": "Mehr durchsuchen", + "no_title": "Kein Titel", + "not_playing": "Wird nicht abgespielt", + "epic_failure": "Episches Versagen!", + "added_num_tracks_to_queue": "{tracks_length} Titel zur Warteschlange hinzugefügt", + "spotube_has_an_update": "Spotube hat ein Update", + "download_now": "Jetzt herunterladen", + "nightly_version": "Spotube Nightly {nightlyBuildNum} wurde veröffentlicht", + "release_version": "Spotube v{version} wurde veröffentlicht", + "read_the_latest": "Lese die neuesten ", + "release_notes": "Versionshinweise", + "pick_color_scheme": "Farbschema wählen", + "save": "Speichern", + "choose_the_device": "Wähle das Gerät:", + "multiple_device_connected": "Es sind mehrere Geräte verbunden.\nWähle das Gerät, auf dem diese Aktion ausgeführt werden soll", + "nothing_found": "Nichts gefunden", + "the_box_is_empty": "Die Box ist leer", + "top_artists": "Top-Künstler", + "top_albums": "Top-Alben", + "this_week": "Diese Woche", + "this_month": "Diesen Monat", + "last_6_months": "Letzte 6 Monate", + "this_year": "Dieses Jahr", + "last_2_years": "Letzte 2 Jahre", + "all_time": "Alle Zeiten", + "powered_by_provider": "Bereitgestellt von {providerName}", + "email": "Email", + "profile_followers": "Follower", + "birthday": "Geburtstag", + "subscription": "Abonnement", + "not_born": "Nicht geboren", + "hacker": "Hacker", + "profile": "Profil", + "no_name": "Kein Name", + "edit": "Bearbeiten", + "user_profile": "Benutzerprofil", + "count_plays": "{count} Wiedergaben", + "streaming_fees_hypothetical": "Streaming-Gebühren (hypothetisch)", + "minutes_listened": "Gehörte Minuten", + "streamed_songs": "Gestreamte Lieder", + "count_streams": "{count} Streams", + "owned_by_you": "In Ihrem Besitz", + "copied_shareurl_to_clipboard": "{shareUrl} in die Zwischenablage kopiert", + "spotify_hipotetical_calculation": "*Dies ist basierend auf Spotifys\npro Stream Auszahlung von $0,003 bis $0,005\nberechnet. Dies ist eine hypothetische Berechnung,\num dem Benutzer Einblick zu geben,\nwieviel sie den Künstlern gezahlt hätten,\nwenn sie ihren Song auf Spotify gehört hätten.", + "count_mins": "{minutes} Minuten", + "summary_minutes": "Minuten", + "summary_listened_to_music": "Hat Musik gehört", + "summary_songs": "Lieder", + "summary_streamed_overall": "Insgesamt gestreamt", + "summary_owed_to_artists": "Den Künstlern geschuldet\nDiesen Monat", + "summary_artists": "Künstler", + "summary_music_reached_you": "Musik hat Sie erreicht", + "summary_full_albums": "volle Alben", + "summary_got_your_love": "Hat Ihre Liebe gewonnen", + "summary_playlists": "Wiedergabelisten", + "summary_were_on_repeat": "Wurden wiederholt", + "total_money": "Gesamt {money}" } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 63c805f4..06a90d79 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -345,7 +345,6 @@ "multiple_device_connected": "There are multiple device connected.\nChoose the device you want this action to take place", "nothing_found": "Nothing found", "the_box_is_empty": "The box is empty", - "top_tracks": "Top Tracks", "top_artists": "Top Artists", "top_albums": "Top Albums", "this_week": "This week", @@ -358,7 +357,6 @@ "email": "Email", "profile_followers": "Followers", "birthday": "Birthday", - "country": "Country", "subscription": "Subscription", "not_born": "Not born", "hacker": "Hacker", @@ -385,5 +383,6 @@ "summary_full_albums": "full albums", "summary_got_your_love": "Got your love", "summary_playlists": "playlists", - "summary_were_on_repeat": "Were on repeat" + "summary_were_on_repeat": "Were on repeat", + "total_money": "Total {money}" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 6558c743..6834d845 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -325,5 +325,64 @@ "add_library_location": "Añadir a la biblioteca", "remove_library_location": "Eliminar de la biblioteca", "local_tab": "Local", - "stats": "Estadísticas" + "stats": "Estadísticas", + "and_n_more": "y {count} más", + "recently_played": "Recién reproducido", + "browse_more": "Explorar más", + "no_title": "Sin título", + "not_playing": "No reproduciendo", + "epic_failure": "¡Fallo épico!", + "added_num_tracks_to_queue": "Se añadieron {tracks_length} canciones a la cola", + "spotube_has_an_update": "Spotube tiene una actualización", + "download_now": "Descargar ahora", + "nightly_version": "Spotube Nightly {nightlyBuildNum} ha sido lanzado", + "release_version": "Spotube v{version} ha sido lanzado", + "read_the_latest": "Lee las últimas ", + "release_notes": "notas de la versión", + "pick_color_scheme": "Elige esquema de color", + "save": "Guardar", + "choose_the_device": "Elige el dispositivo:", + "multiple_device_connected": "Hay múltiples dispositivos conectados.\nElige el dispositivo en el que deseas realizar esta acción", + "nothing_found": "Nada encontrado", + "the_box_is_empty": "La caja está vacía", + "top_artists": "Artistas principales", + "top_albums": "Álbumes principales", + "this_week": "Esta semana", + "this_month": "Este mes", + "last_6_months": "Últimos 6 meses", + "this_year": "Este año", + "last_2_years": "Últimos 2 años", + "all_time": "Todos los tiempos", + "powered_by_provider": "Impulsado por {providerName}", + "email": "Correo electrónico", + "profile_followers": "Seguidores", + "birthday": "Cumpleaños", + "subscription": "Suscripción", + "not_born": "No nacido", + "hacker": "Hacker", + "profile": "Perfil", + "no_name": "Sin nombre", + "edit": "Editar", + "user_profile": "Perfil de usuario", + "count_plays": "{count} reproducciones", + "streaming_fees_hypothetical": "Tarifas de streaming (hipotéticas)", + "minutes_listened": "Minutos escuchados", + "streamed_songs": "Canciones reproducidas", + "count_streams": "{count} streams", + "owned_by_you": "En tu posesión", + "copied_shareurl_to_clipboard": "Copiado {shareUrl} al portapapeles", + "spotify_hipotetical_calculation": "*Esto se calcula en base al\npago por stream de Spotify de $0.003 a $0.005.\nEs un cálculo hipotético para dar\nuna idea de cuánto habría\npagado a los artistas si hubieras escuchado\nsu canción en Spotify.", + "count_mins": "{minutes} minutos", + "summary_minutes": "minutos", + "summary_listened_to_music": "Escuchó música", + "summary_songs": "canciones", + "summary_streamed_overall": "Transmitido en general", + "summary_owed_to_artists": "Debido a los artistas\nEste mes", + "summary_artists": "artistas", + "summary_music_reached_you": "La música te alcanzó", + "summary_full_albums": "álbumes completos", + "summary_got_your_love": "Obtuvo tu amor", + "summary_playlists": "listas de reproducción", + "summary_were_on_repeat": "Estaban en repetición", + "total_money": "Total {money}" } \ No newline at end of file diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb index 8f041581..6cc41620 100644 --- a/lib/l10n/app_eu.arb +++ b/lib/l10n/app_eu.arb @@ -345,7 +345,6 @@ "multiple_device_connected": "Hainbat gailu daude konektatuta.\nAukeratu zein gailutan aplikatu nahi duzun ekintza hau", "nothing_found": "Ezer ez da aurkitu", "the_box_is_empty": "Kaxa hutsik dago", - "top_tracks": "Top Kantak", "top_artists": "Top Artistak", "top_albums": "Top Albumak", "this_week": "Aste honetan", @@ -358,7 +357,6 @@ "email": "Email", "profile_followers": "Jarraitzaileak", "birthday": "Jaiotze-data", - "country": "Herrialdea", "subscription": "Harpidetzak", "not_born": "Jaio gabe", "hacker": "Hacker", @@ -385,5 +383,6 @@ "summary_full_albums": "album osok", "summary_got_your_love": "Izan dute zure maitasuna", "summary_playlists": "zerrenda", - "summary_were_on_repeat": "Dituzu errepikatze moduan" + "summary_were_on_repeat": "Dituzu errepikatze moduan", + "total_money": "Guztira {money}" } \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index b939de59..5611e0cc 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -325,5 +325,64 @@ "add_library_location": "اضافه کردن به کتابخانه", "remove_library_location": "حذف از کتابخانه", "local_tab": "محلی", - "stats": "آمار" + "stats": "آمار", + "and_n_more": "و {count} بیشتر", + "recently_played": "اخیراً پخش شده", + "browse_more": "بیشتر مرور کنید", + "no_title": "بدون عنوان", + "not_playing": "در حال پخش نیست", + "epic_failure": "شکست حماسی!", + "added_num_tracks_to_queue": "{tracks_length} ترک به صف اضافه شد", + "spotube_has_an_update": "Spotube یک بروزرسانی دارد", + "download_now": "اکنون دانلود کنید", + "nightly_version": "نسخه شبانه Spotube {nightlyBuildNum} منتشر شد", + "release_version": "نسخه Spotube v{version} منتشر شد", + "read_the_latest": "آخرین‌ها را بخوانید", + "release_notes": "یادداشت‌های انتشار", + "pick_color_scheme": "طرح رنگ را انتخاب کنید", + "save": "ذخیره", + "choose_the_device": "دستگاه را انتخاب کنید:", + "multiple_device_connected": "چندین دستگاه متصل هستند.\nدستگاهی را انتخاب کنید که می‌خواهید این عملیات بر روی آن انجام شود", + "nothing_found": "چیزی پیدا نشد", + "the_box_is_empty": "جعبه خالی است", + "top_artists": "بهترین هنرمندان", + "top_albums": "بهترین آلبوم‌ها", + "this_week": "این هفته", + "this_month": "این ماه", + "last_6_months": "۶ ماه گذشته", + "this_year": "امسال", + "last_2_years": "۲ سال گذشته", + "all_time": "همیشه", + "powered_by_provider": "توسط {providerName} پشتیبانی شده است", + "email": "ایمیل", + "profile_followers": "دنبال‌کنندگان", + "birthday": "تولد", + "subscription": "اشتراک", + "not_born": "متولد نشده", + "hacker": "هکر", + "profile": "پروفایل", + "no_name": "بدون نام", + "edit": "ویرایش", + "user_profile": "پروفایل کاربر", + "count_plays": "{count} پخش", + "streaming_fees_hypothetical": "هزینه‌های پخش (فرضی)", + "minutes_listened": "دقایق گوش داده شده", + "streamed_songs": "ترانه‌های پخش شده", + "count_streams": "{count} پخش", + "owned_by_you": "توسط شما مالکیت شده", + "copied_shareurl_to_clipboard": "{shareUrl} به کلیپ‌بورد کپی شد", + "spotify_hipotetical_calculation": "*این بر اساس پرداخت هر پخش اسپاتیفای\nبه مبلغ 0.003 تا 0.005 دلار محاسبه شده است.\nاین یک محاسبه فرضی است که به کاربران نشان دهد چقدر ممکن است\nبه هنرمندان پرداخت می‌کردند اگر ترانه آنها را در اسپاتیفای گوش می‌دادند.", + "count_mins": "{minutes} دقیقه", + "summary_minutes": "دقیقه‌ها", + "summary_listened_to_music": "به موسیقی گوش داده شده", + "summary_songs": "ترانه‌ها", + "summary_streamed_overall": "پخش شده به طور کلی", + "summary_owed_to_artists": "به هنرمندان بدهکار است\nاین ماه", + "summary_artists": "هنرمندان", + "summary_music_reached_you": "موسیقی به شما رسیده است", + "summary_full_albums": "آلبوم‌های کامل", + "summary_got_your_love": "عشق شما را به دست آورد", + "summary_playlists": "لیست‌های پخش", + "summary_were_on_repeat": "در تکرار بودند", + "total_money": "مجموع {money}" } \ No newline at end of file diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index d0767e95..57f209ab 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -325,5 +325,64 @@ "add_library_location": "Lisää kirjastoon", "remove_library_location": "Poista kirjastosta", "local_tab": "Paikallinen", - "stats": "Tilastot" + "stats": "Tilastot", + "and_n_more": "ja {count} lisää", + "recently_played": "Äskettäin soitetut", + "browse_more": "Selaa lisää", + "no_title": "Ei otsikkoa", + "not_playing": "Ei soi", + "epic_failure": "Epäonnistuminen!", + "added_num_tracks_to_queue": "Lisätty {tracks_length} kappaletta jonoon", + "spotube_has_an_update": "Spotubella on päivitys", + "download_now": "Lataa nyt", + "nightly_version": "Spotube Nightly {nightlyBuildNum} on julkaistu", + "release_version": "Spotube v{version} on julkaistu", + "read_the_latest": "Lue viimeisimmät", + "release_notes": "julkaisumuistiinpanot", + "pick_color_scheme": "Valitse värimaailma", + "save": "Tallenna", + "choose_the_device": "Valitse laite:", + "multiple_device_connected": "Useita laitteita on kytketty.\nValitse laite, jossa haluat toiminnon suorittaa", + "nothing_found": "Ei tuloksia", + "the_box_is_empty": "Laatikko on tyhjä", + "top_artists": "Suosituimmat artistit", + "top_albums": "Suosituimmat albumit", + "this_week": "Tällä viikolla", + "this_month": "Tässä kuussa", + "last_6_months": "Viimeiset 6 kuukautta", + "this_year": "Tänä vuonna", + "last_2_years": "Viimeiset 2 vuotta", + "all_time": "Kaikki ajat", + "powered_by_provider": "Tuottanut {providerName}", + "email": "Sähköposti", + "profile_followers": "Seuraajat", + "birthday": "Syntymäpäivä", + "subscription": "Tilaus", + "not_born": "Ei syntynyt", + "hacker": "Hakkeri", + "profile": "Profiili", + "no_name": "Ei nimeä", + "edit": "Muokkaa", + "user_profile": "Käyttäjäprofiili", + "count_plays": "{count} toistoa", + "streaming_fees_hypothetical": "Suoratoiston maksut (hypoteettinen)", + "minutes_listened": "Kuunneltuja minuutteja", + "streamed_songs": "Suoratoistettuja kappaleita", + "count_streams": "{count} suoratoistoa", + "owned_by_you": "Sinun omistama", + "copied_shareurl_to_clipboard": "{shareUrl} kopioitu leikepöydälle", + "spotify_hipotetical_calculation": "*Tämä on laskettu Spotifyn suoratoiston\nmaksun perusteella, joka on 0,003–0,005 dollaria.\nTämä on hypoteettinen laskelma, joka antaa käyttäjälle käsityksen\nsiitä, kuinka paljon he olisivat maksaneet artisteille,\njollei heidän kappaleensa olisi kuunneltu Spotifyssa.", + "count_mins": "{minutes} min", + "summary_minutes": "minuuttia", + "summary_listened_to_music": "Kuunneltu musiikkia", + "summary_songs": "kappaletta", + "summary_streamed_overall": "Suoratoistettu yhteensä", + "summary_owed_to_artists": "Maksettava artisteille\nTässä kuussa", + "summary_artists": "artisti", + "summary_music_reached_you": "Musiikki saavutti sinut", + "summary_full_albums": "täydet albumit", + "summary_got_your_love": "Sai rakkautesi", + "summary_playlists": "soittolistat", + "summary_were_on_repeat": "Olivat toistossa", + "total_money": "Yhteensä {money}" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 6bd2d0f8..4a41dec9 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -325,5 +325,64 @@ "add_library_location": "Ajouter à la bibliothèque", "remove_library_location": "Retirer de la bibliothèque", "local_tab": "Local", - "stats": "Statistiques" + "stats": "Statistiques", + "and_n_more": "et {count} de plus", + "recently_played": "Récemment joué", + "browse_more": "Parcourir plus", + "no_title": "Sans titre", + "not_playing": "Non joué", + "epic_failure": "Échec épique!", + "added_num_tracks_to_queue": "{tracks_length} morceaux ajoutés à la file d'attente", + "spotube_has_an_update": "Spotube a une mise à jour", + "download_now": "Télécharger maintenant", + "nightly_version": "Spotube Nightly {nightlyBuildNum} a été publié", + "release_version": "Spotube v{version} a été publié", + "read_the_latest": "Lisez les dernières ", + "release_notes": "notes de version", + "pick_color_scheme": "Choisissez le schéma de couleurs", + "save": "Sauvegarder", + "choose_the_device": "Choisissez l'appareil:", + "multiple_device_connected": "Plusieurs appareils sont connectés.\nChoisissez l'appareil sur lequel vous souhaitez effectuer cette action", + "nothing_found": "Rien trouvé", + "the_box_is_empty": "La boîte est vide", + "top_artists": "Meilleurs artistes", + "top_albums": "Meilleurs albums", + "this_week": "Cette semaine", + "this_month": "Ce mois-ci", + "last_6_months": "Les 6 derniers mois", + "this_year": "Cette année", + "last_2_years": "Les 2 dernières années", + "all_time": "De tous les temps", + "powered_by_provider": "Propulsé par {providerName}", + "email": "Email", + "profile_followers": "Abonnés", + "birthday": "Anniversaire", + "subscription": "Abonnement", + "not_born": "Non né", + "hacker": "Hacker", + "profile": "Profil", + "no_name": "Sans nom", + "edit": "Modifier", + "user_profile": "Profil utilisateur", + "count_plays": "{count} lectures", + "streaming_fees_hypothetical": "Frais de streaming (hypothétiques)", + "minutes_listened": "Minutes écoutées", + "streamed_songs": "Morceaux diffusés", + "count_streams": "{count} streams", + "owned_by_you": "Possédé par vous", + "copied_shareurl_to_clipboard": "{shareUrl} copié dans le presse-papier", + "spotify_hipotetical_calculation": "*Cela est calculé en fonction du\npaiement par stream de Spotify de 0,003 $ à 0,005 $.\nIl s'agit d'un calcul hypothétique pour donner\nune idée de combien vous auriez\npayé aux artistes si vous aviez\nécouté leur chanson sur Spotify.", + "count_mins": "{minutes} minutes", + "summary_minutes": "minutes", + "summary_listened_to_music": "A écouté de la musique", + "summary_songs": "morceaux", + "summary_streamed_overall": "Diffusé en général", + "summary_owed_to_artists": "Dû aux artistes\nCe mois-ci", + "summary_artists": "artistes", + "summary_music_reached_you": "La musique vous a atteint", + "summary_full_albums": "albums complets", + "summary_got_your_love": "A obtenu votre amour", + "summary_playlists": "playlists", + "summary_were_on_repeat": "Était en répétition", + "total_money": "Total {money}" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 7dc809c7..a65e3f75 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -325,5 +325,64 @@ "add_library_location": "पुस्तकालय में जोड़ें", "remove_library_location": "पुस्तकालय से हटाएं", "local_tab": "स्थानीय", - "stats": "आंकड़े" + "stats": "आंकड़े", + "and_n_more": "और {count} और", + "recently_played": "हाल ही में खेले गए", + "browse_more": "अधिक ब्राउज़ करें", + "no_title": "कोई शीर्षक नहीं", + "not_playing": "नहीं चल रहा", + "epic_failure": "महान असफलता!", + "added_num_tracks_to_queue": "{tracks_length} ट्रैक्स कतार में जोड़े गए", + "spotube_has_an_update": "Spotube में एक अपडेट है", + "download_now": "अभी डाउनलोड करें", + "nightly_version": "Spotube Nightly {nightlyBuildNum} जारी किया गया है", + "release_version": "Spotube v{version} जारी किया गया है", + "read_the_latest": "नवीनतम पढ़ें", + "release_notes": "रिलीज़ नोट्स", + "pick_color_scheme": "रंग योजना चुनें", + "save": "सहेजें", + "choose_the_device": "उपकरण चुनें:", + "multiple_device_connected": "कई उपकरण जुड़े हुए हैं।\nउस उपकरण को चुनें जिस पर आप यह क्रिया करना चाहते हैं", + "nothing_found": "कुछ भी नहीं मिला", + "the_box_is_empty": "बॉक्स खाली है", + "top_artists": "शीर्ष कलाकार", + "top_albums": "शीर्ष एल्बम", + "this_week": "इस हफ्ते", + "this_month": "इस महीने", + "last_6_months": "पिछले 6 महीने", + "this_year": "इस साल", + "last_2_years": "पिछले 2 साल", + "all_time": "सभी समय", + "powered_by_provider": "{providerName} द्वारा संचालित", + "email": "ईमेल", + "profile_followers": "अनुयायी", + "birthday": "जन्मदिन", + "subscription": "सदस्यता", + "not_born": "अभी पैदा नहीं हुआ", + "hacker": "हैकर", + "profile": "प्रोफ़ाइल", + "no_name": "कोई नाम नहीं", + "edit": "संपादित करें", + "user_profile": "उपयोगकर्ता प्रोफ़ाइल", + "count_plays": "{count} प्ले", + "streaming_fees_hypothetical": "*Spotify की प्रति स्ट्रीम भुगतान के आधार पर\n$0.003 से $0.005 तक गणना की गई है। यह एक काल्पनिक\nगणना है जो उपयोगकर्ता को यह जानकारी देती है कि वे कितना भुगतान\nकरते यदि वे Spotify पर गाने सुनते।", + "count_mins": "{minutes} मिनट", + "summary_minutes": "मिनट", + "summary_listened_to_music": "सुनी गई संगीत", + "summary_songs": "गाने", + "summary_streamed_overall": "कुल स्ट्रीम", + "summary_owed_to_artists": "कलाकारों को देनदार\nइस महीने", + "summary_artists": "कलाकार", + "summary_music_reached_you": "संगीत आपके पास पहुंच गया", + "summary_full_albums": "पूरा एल्बम", + "summary_got_your_love": "आपका प्यार मिला", + "summary_playlists": "प्लेलिस्ट", + "summary_were_on_repeat": "दोहराया गया", + "total_money": "कुल {money}", + "minutes_listened": "सुनिएका मिनेटहरू", + "streamed_songs": "स्ट्रीम गरिएका गीतहरू", + "count_streams": "{count} स्ट्रिम", + "owned_by_you": "तपाईंले स्वामित्व गरेको", + "copied_shareurl_to_clipboard": "{shareUrl} क्लिपबोर्डमा कपी गरियो", + "spotify_hipotetical_calculation": "*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n$0.003 देखि $0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।" } \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 669f5e2a..0a417c40 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -325,5 +325,64 @@ "add_library_location": "Tambahkan ke perpustakaan", "remove_library_location": "Hapus dari perpustakaan", "local_tab": "Lokal", - "stats": "Statistik" + "stats": "Statistik", + "and_n_more": "dan {count} lainnya", + "recently_played": "Baru saja diputar", + "browse_more": "Telusuri lebih banyak", + "no_title": "Tanpa judul", + "not_playing": "Tidak diputar", + "epic_failure": "Kegagalan epik!", + "added_num_tracks_to_queue": "Menambahkan {tracks_length} trek ke antrean", + "spotube_has_an_update": "Spotube memiliki pembaruan", + "download_now": "Unduh sekarang", + "nightly_version": "Spotube Nightly {nightlyBuildNum} telah dirilis", + "release_version": "Spotube v{version} telah dirilis", + "read_the_latest": "Baca yang terbaru ", + "release_notes": "catatan rilis", + "pick_color_scheme": "Pilih skema warna", + "save": "Simpan", + "choose_the_device": "Pilih perangkat:", + "multiple_device_connected": "Beberapa perangkat terhubung.\nPilih perangkat tempat Anda ingin melakukan tindakan ini", + "nothing_found": "Tidak ditemukan apa pun", + "the_box_is_empty": "Kotak kosong", + "top_artists": "Artis Teratas", + "top_albums": "Album Teratas", + "this_week": "Minggu ini", + "this_month": "Bulan ini", + "last_6_months": "6 bulan terakhir", + "this_year": "Tahun ini", + "last_2_years": "2 tahun terakhir", + "all_time": "Sepanjang waktu", + "powered_by_provider": "Didukung oleh {providerName}", + "email": "Email", + "profile_followers": "Pengikut", + "birthday": "Ulang Tahun", + "subscription": "Langganan", + "not_born": "Belum lahir", + "hacker": "Hacker", + "profile": "Profil", + "no_name": "Tanpa nama", + "edit": "Edit", + "user_profile": "Profil pengguna", + "count_plays": "{count} pemutaran", + "streaming_fees_hypothetical": "Biaya streaming (hipotetis)", + "minutes_listened": "Menit didengarkan", + "streamed_songs": "Lagu yang disiarkan", + "count_streams": "{count} streams", + "owned_by_you": "Dimiliki oleh Anda", + "copied_shareurl_to_clipboard": "{shareUrl} disalin ke clipboard", + "spotify_hipotetical_calculation": "*Ini dihitung berdasarkan pembayaran\nper stream Spotify dari $0,003 hingga $0,005.\nIni adalah perhitungan hipotetis untuk memberi\npengguna gambaran tentang berapa banyak\nmereka akan membayar kepada artis jika\nmereka mendengarkan lagu mereka di Spotify.", + "count_mins": "{minutes} menit", + "summary_minutes": "menit", + "summary_listened_to_music": "Mendengarkan musik", + "summary_songs": "lagu", + "summary_streamed_overall": "Disiarkan secara keseluruhan", + "summary_owed_to_artists": "Terhutang kepada artis\nBulan ini", + "summary_artists": "artis", + "summary_music_reached_you": "Musik mencapai Anda", + "summary_full_albums": "album lengkap", + "summary_got_your_love": "Mendapatkan cinta Anda", + "summary_playlists": "daftar putar", + "summary_were_on_repeat": "Sedang diulang", + "total_money": "Total {money}" } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 9ba30acc..6cbcbb6a 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -326,5 +326,64 @@ "add_library_location": "Aggiungi alla biblioteca", "remove_library_location": "Rimuovi dalla biblioteca", "local_tab": "Locale", - "stats": "Statistiche" + "stats": "Statistiche", + "and_n_more": "e {count} in più", + "recently_played": "Riprodotti di recente", + "browse_more": "Esplora di più", + "no_title": "Nessun titolo", + "not_playing": "Non in riproduzione", + "epic_failure": "Fallimento epico!", + "added_num_tracks_to_queue": "Aggiunti {tracks_length} brani alla coda", + "spotube_has_an_update": "Spotube ha un aggiornamento", + "download_now": "Scarica ora", + "nightly_version": "Spotube Nightly {nightlyBuildNum} è stato rilasciato", + "release_version": "Spotube v{version} è stato rilasciato", + "read_the_latest": "Leggi l'ultimo ", + "release_notes": "note di rilascio", + "pick_color_scheme": "Scegli uno schema di colori", + "save": "Salva", + "choose_the_device": "Scegli il dispositivo:", + "multiple_device_connected": "Sono collegati più dispositivi.\nScegli il dispositivo su cui vuoi che venga eseguita questa azione", + "nothing_found": "Nessun risultato", + "the_box_is_empty": "La scatola è vuota", + "top_artists": "Artisti Top", + "top_albums": "Album Top", + "this_week": "Questa settimana", + "this_month": "Questo mese", + "last_6_months": "Ultimi 6 mesi", + "this_year": "Quest'anno", + "last_2_years": "Ultimi 2 anni", + "all_time": "Di tutti i tempi", + "powered_by_provider": "Sostenuto da {providerName}", + "email": "Email", + "profile_followers": "Follower", + "birthday": "Compleanno", + "subscription": "Abbonamento", + "not_born": "Non nato", + "hacker": "Hacker", + "profile": "Profilo", + "no_name": "Nessun nome", + "edit": "Modifica", + "user_profile": "Profilo utente", + "count_plays": "{count} riproduzioni", + "streaming_fees_hypothetical": "Spese di streaming (ipotetico)", + "minutes_listened": "Minuti ascoltati", + "streamed_songs": "Brani in streaming", + "count_streams": "{count} streaming", + "owned_by_you": "Di tua proprietà", + "copied_shareurl_to_clipboard": "Copiato {shareUrl} negli appunti", + "spotify_hipotetical_calculation": "*Questo è calcolato in base al pagamento per streaming di Spotify\nche va da $0.003 a $0.005. Questo è un calcolo ipotetico\nper dare all'utente un'idea di quanto avrebbe pagato agli artisti se avesse ascoltato\ne loro canzoni su Spotify.", + "count_mins": "{minutes} min", + "summary_minutes": "minuti", + "summary_listened_to_music": "Musica ascoltata", + "summary_songs": "brani", + "summary_streamed_overall": "Streaming complessivo", + "summary_owed_to_artists": "Dovuto agli artisti\nquesto mese", + "summary_artists": "dell'artista", + "summary_music_reached_you": "La musica ti ha raggiunto", + "summary_full_albums": "album completi", + "summary_got_your_love": "Ha ricevuto il tuo amore", + "summary_playlists": "playlist", + "summary_were_on_repeat": "Erano in ripetizione", + "total_money": "Totale {money}" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 35e76b69..a26c8ba0 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -325,5 +325,64 @@ "add_library_location": "ライブラリに追加", "remove_library_location": "ライブラリから削除", "local_tab": "ローカル", - "stats": "統計" + "stats": "統計", + "and_n_more": "そして {count} つのアイテム", + "recently_played": "最近再生された", + "browse_more": "もっと見る", + "no_title": "タイトルなし", + "not_playing": "再生中ではありません", + "epic_failure": "壮大な失敗!", + "added_num_tracks_to_queue": "{tracks_length} 曲をキューに追加しました", + "spotube_has_an_update": "Spotube にアップデートがあります", + "download_now": "今すぐダウンロード", + "nightly_version": "Spotube Nightly {nightlyBuildNum} がリリースされました", + "release_version": "Spotube v{version} がリリースされました", + "read_the_latest": "最新の ", + "release_notes": "リリースノート", + "pick_color_scheme": "カラースキームを選択", + "save": "保存", + "choose_the_device": "デバイスを選択:", + "multiple_device_connected": "複数のデバイスが接続されています。\nこのアクションを実行するデバイスを選択してください", + "nothing_found": "何も見つかりませんでした", + "the_box_is_empty": "ボックスは空です", + "top_artists": "トップアーティスト", + "top_albums": "トップアルバム", + "this_week": "今週", + "this_month": "今月", + "last_6_months": "過去6か月", + "this_year": "今年", + "last_2_years": "過去2年間", + "all_time": "全期間", + "powered_by_provider": "{providerName} 提供", + "email": "メール", + "profile_followers": "フォロワー", + "birthday": "誕生日", + "subscription": "サブスクリプション", + "not_born": "未出生", + "hacker": "ハッカー", + "profile": "プロフィール", + "no_name": "名前なし", + "edit": "編集", + "user_profile": "ユーザープロフィール", + "count_plays": "{count} 回再生", + "streaming_fees_hypothetical": "*これは Spotify のストリームあたりの支払い\nが $0.003 から $0.005 であると仮定して計算されています。\nこれは、Spotify でその曲を聴いた場合にアーティストにいくら支払ったかの\n洞察を得るための仮定の計算です。", + "count_mins": "{minutes} 分", + "summary_minutes": "分", + "summary_listened_to_music": "音楽を聴いた", + "summary_songs": "曲", + "summary_streamed_overall": "全体のストリーミング", + "summary_owed_to_artists": "今月アーティストに支払うべき額", + "summary_artists": "アーティストの", + "summary_music_reached_you": "音楽があなたに届いた", + "summary_full_albums": "フルアルバム", + "summary_got_your_love": "あなたの愛を受け取った", + "summary_playlists": "プレイリスト", + "summary_were_on_repeat": "リピートしていた", + "total_money": "合計 {money}", + "minutes_listened": "リスニング時間", + "streamed_songs": "ストリーミングされた曲", + "count_streams": "{count} 回のストリーム", + "owned_by_you": "あなたが所有", + "copied_shareurl_to_clipboard": "{shareUrl} をクリップボードにコピーしました", + "spotify_hipotetical_calculation": "*これは、Spotifyのストリームごとの支払い\nが $0.003 から $0.005 の範囲で計算されています。これは仮想的な\n計算で、Spotify で曲を聴いた場合に、アーティストに\nどれくらい支払ったかをユーザーに示すためのものです。" } \ No newline at end of file diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb index 28fcc26a..66d7f888 100644 --- a/lib/l10n/app_ka.arb +++ b/lib/l10n/app_ka.arb @@ -325,5 +325,64 @@ "add_library_location": "ბიბლიოთეკაში დამატება", "remove_library_location": "ბიბლიოთეკიდან წაშლა", "local_tab": "ადგილობრივი", - "stats": "სტატისტიკა" + "stats": "სტატისტიკა", + "and_n_more": "და {count} მეტი", + "recently_played": "მიუწვდელი", + "browse_more": "დაიცალეთ მეტი", + "no_title": "არ აქვს სათაური", + "not_playing": "არ ერთვის", + "epic_failure": "ეპიკური მარცხი!", + "added_num_tracks_to_queue": "დამატებული {tracks_length} ტრეკი რიგში", + "spotube_has_an_update": "Spotube-ს აქვს განახლება", + "download_now": "ჩამოტვირთეთ ახლავე", + "nightly_version": "Spotube Nightly {nightlyBuildNum} გამოშვებულია", + "release_version": "Spotube v{version} გამოშვებულია", + "read_the_latest": "წაიკითხეთ უახლესი ", + "release_notes": "გამოშვების შენიშვნები", + "pick_color_scheme": "აირჩიეთ ფერის სქემა", + "save": "შეინახეთ", + "choose_the_device": "აირჩიეთ მოწყობილობა:", + "multiple_device_connected": "დაკავშირებულია რამდენიმე მოწყობილობა.\nაირჩიეთ მოწყობილობა, რომელზეც უნდა განხორციელდეს ეს მოქმედება", + "nothing_found": "არაფერი მოიძებნა", + "the_box_is_empty": "კვადრატია ცარიელი", + "top_artists": "ტოპ არტისტები", + "top_albums": "ტოპ ალბომები", + "this_week": "ამ კვირას", + "this_month": "ამ თვეში", + "last_6_months": "ბოლო 6 თვე", + "this_year": "ამ წელს", + "last_2_years": "ბოლო 2 წელი", + "all_time": "ყველა დრო", + "powered_by_provider": "{providerName}-ით გაწვდილი", + "email": "ელ. ფოსტა", + "profile_followers": "გამყვანები", + "birthday": "დაბადების დღე", + "subscription": "გამოწერა", + "not_born": "არ დაბადებულა", + "hacker": "ჰაკერი", + "profile": "პროფილი", + "no_name": "არ არის სახელი", + "edit": "რედაქტირება", + "user_profile": "მომხმარებლის პროფილი", + "count_plays": "{count} გაწვდვა", + "streaming_fees_hypothetical": "*ეს рассчитывается на основе выплат за поток от Spotify\nот $0.003 до $0.005. ეს ჰიპოთეტური გამოთვლა იძლევა მომხმარებელს წარმოდგენას იმაზე, რამდენად\nგადახდილი იქნებოდა არტისტებისთვის, თუ მათ მოუსმინოს Spotify-ს ტრეკებს.", + "count_mins": "{minutes} წუთი", + "summary_minutes": "წუთები", + "summary_listened_to_music": "მუსიკა გაწვდილი", + "summary_songs": "მელოდია", + "summary_streamed_overall": "გაწვდილი საერთო", + "summary_owed_to_artists": "გადასახადი არტისტებს\nამ თვეში", + "summary_artists": "არტისტების", + "summary_music_reached_you": "მუსიკა ჩაგივარდა", + "summary_full_albums": "სრული ალბომები", + "summary_got_your_love": "მოსულა თქვენი სიყვარული", + "summary_playlists": "პლეილისტები", + "summary_were_on_repeat": "გადაწვდილი იყო", + "total_money": "მთლიანი {money}", + "minutes_listened": "წუთები მოუსმინეს", + "streamed_songs": "სტრიმირებული სიმღერები", + "count_streams": "{count} სტრიმი", + "owned_by_you": "შენ მიერ საკუთრებული", + "copied_shareurl_to_clipboard": "{shareUrl} აიღო კლიპბორდზე", + "spotify_hipotetical_calculation": "*ეს გამოითვლება Spotify-ის თითოეულ სტრიმზე\nგადახდის შესაბამისად, რომელიც $0.003 დან $0.005-მდეა. ეს არის ჰიპოთეტური\nგამოთვლა, რომელიც აჩვენებს მომხმარებელს რამდენი გადაიხდიდა\nარტისტებს, თუკი ისინი უსმენდნენ მათ სიმღერებს Spotify-ზე." } \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index cb6e0999..10036ba5 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -326,5 +326,64 @@ "add_library_location": "도서관에 추가", "remove_library_location": "도서관에서 제거", "local_tab": "로컬", - "stats": "통계" + "stats": "통계", + "and_n_more": "그리고 {count}개 더", + "recently_played": "최근 재생", + "browse_more": "더 보기", + "no_title": "제목 없음", + "not_playing": "재생 중이 아님", + "epic_failure": "서사적 실패!", + "added_num_tracks_to_queue": "{tracks_length} 곡을 대기열에 추가했습니다", + "spotube_has_an_update": "Spotube에 업데이트가 있습니다", + "download_now": "지금 다운로드", + "nightly_version": "Spotube Nightly {nightlyBuildNum}이 출시되었습니다", + "release_version": "Spotube v{version}이 출시되었습니다", + "read_the_latest": "최신 ", + "release_notes": "릴리스 노트", + "pick_color_scheme": "색상 테마 선택", + "save": "저장", + "choose_the_device": "디바이스 선택:", + "multiple_device_connected": "여러 디바이스가 연결되어 있습니다.\n이 작업을 실행할 디바이스를 선택하세요", + "nothing_found": "찾을 수 없음", + "the_box_is_empty": "상자가 비어 있습니다", + "top_artists": "톱 아티스트", + "top_albums": "톱 앨범", + "this_week": "이번 주", + "this_month": "이번 달", + "last_6_months": "지난 6개월", + "this_year": "올해", + "last_2_years": "지난 2년", + "all_time": "모든 시간", + "powered_by_provider": "{providerName} 제공", + "email": "이메일", + "profile_followers": "팔로워", + "birthday": "생일", + "subscription": "구독", + "not_born": "태어나지 않음", + "hacker": "해커", + "profile": "프로필", + "no_name": "이름 없음", + "edit": "편집", + "user_profile": "사용자 프로필", + "count_plays": "{count} 재생", + "streaming_fees_hypothetical": "*이것은 Spotify의 스트림당 지급액\n$0.003에서 $0.005를 기준으로 계산된 것입니다.\n이것은 사용자가 Spotify에서 곡을 들었을 때\n아티스트에게 지불했을 금액에 대한 통찰을 제공하기 위한\n가상의 계산입니다.", + "count_mins": "{minutes} 분", + "summary_minutes": "분", + "summary_listened_to_music": "듣는 음악", + "summary_songs": "곡", + "summary_streamed_overall": "전체 스트리밍", + "summary_owed_to_artists": "이번 달 아티스트에게 지급해야 할 금액", + "summary_artists": "아티스트의", + "summary_music_reached_you": "음악이 도달함", + "summary_full_albums": "전체 앨범", + "summary_got_your_love": "당신의 사랑을 받음", + "summary_playlists": "플레이리스트", + "summary_were_on_repeat": "반복 재생됨", + "total_money": "총 {money}", + "minutes_listened": "청취한 시간", + "streamed_songs": "스트리밍된 곡", + "count_streams": "{count} 스트림", + "owned_by_you": "당신이 소유", + "copied_shareurl_to_clipboard": "{shareUrl}를 클립보드에 복사했습니다", + "spotify_hipotetical_calculation": "*Spotify의 스트림당 지불금 $0.003에서 $0.005까지의\n기준으로 계산되었습니다. 이는 사용자가 Spotify에서\n곡을 들을 때 아티스트에게 얼마를 지불했을지를\n알려주기 위한 가상의 계산입니다." } \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb index f8e8d46a..ce2a1e4b 100644 --- a/lib/l10n/app_ne.arb +++ b/lib/l10n/app_ne.arb @@ -325,5 +325,64 @@ "add_library_location": "पुस्तकालयमा थप्नुहोस्", "remove_library_location": "पुस्तकालयबाट हटाउनुहोस्", "local_tab": "स्थानिय", - "stats": "तथ्याङ्क" + "stats": "तथ्याङ्क", + "and_n_more": "राम्रो {count} थप", + "recently_played": "हालै खेलेको", + "browse_more": "थप हेर्नुहोस्", + "no_title": "शीर्षक छैन", + "not_playing": "खेलिरहेको छैन", + "epic_failure": "महाकवि असफलता!", + "added_num_tracks_to_queue": "{tracks_length} ट्र्याकहरू तालिकामा थपिएका छन्", + "spotube_has_an_update": "Spotube मा अपडेट छ", + "download_now": "अहिले डाउनलोड गर्नुहोस्", + "nightly_version": "Spotube Nightly {nightlyBuildNum} रिलिज गरिएको छ", + "release_version": "Spotube v{version} रिलिज गरिएको छ", + "read_the_latest": "अर्को ", + "release_notes": "रिलिज नोटहरू", + "pick_color_scheme": "रंग योजना चयन गर्नुहोस्", + "save": "सुरक्षित गर्नुहोस्", + "choose_the_device": "उपकरण चयन गर्नुहोस्:", + "multiple_device_connected": "धेरै उपकरण जडान गरिएको छ।\nयो क्रियाकलाप गर्ने उपकरण चयन गर्नुहोस्", + "nothing_found": "केही फेला परेन", + "the_box_is_empty": "बक्स खाली छ", + "top_artists": "शीर्ष कलाकारहरू", + "top_albums": "शीर्ष एल्बमहरू", + "this_week": "यो हप्ता", + "this_month": "यो महिना", + "last_6_months": "पछिल्लो ६ महिना", + "this_year": "यो वर्ष", + "last_2_years": "पछिल्लो २ वर्ष", + "all_time": "सबै समय", + "powered_by_provider": "{providerName} द्वारा शक्ति प्राप्त", + "email": "ईमेल", + "profile_followers": "अनुयायीहरू", + "birthday": "जन्मदिन", + "subscription": "सदस्यता", + "not_born": "जन्मिएको छैन", + "hacker": "ह्याकर", + "profile": "प्रोफाइल", + "no_name": "नाम छैन", + "edit": "सम्पादन गर्नुहोस्", + "user_profile": "प्रयोगकर्ता प्रोफाइल", + "count_plays": "{count} खेलाइन्छ", + "streaming_fees_hypothetical": "*यो Spotify को प्रति स्ट्रिमको आधारमा गणना गरिएको छ\n$0.003 देखि $0.005 बीचको भुक्तानी। यो एक काल्पनिक गणना हो\nउपयोगकर्तालाई यो थाहा दिनको लागि कि उनीहरूले अर्टिस्टहरूलाई\nSpotify मा गीत सुनेको भए कति भुक्तानी गर्ने थिए।", + "count_mins": "{minutes} मिनेट", + "summary_minutes": "मिनेट", + "summary_listened_to_music": "सङ्गीत सुन्नु", + "summary_songs": "गीतहरू", + "summary_streamed_overall": "सामान्य रूपले स्ट्रीम गरिएको", + "summary_owed_to_artists": "यस महिना कलाकारहरूलाई देन", + "summary_artists": "कलाकारको", + "summary_music_reached_you": "सङ्गीत तपाईंलाई पुग्यो", + "summary_full_albums": "पूर्ण एल्बमहरू", + "summary_got_your_love": "तपाईंको माया प्राप्त गरियो", + "summary_playlists": "प्लेइस्ट", + "summary_were_on_repeat": "पुनरावृत्ति गरियो", + "total_money": "कुल {money}", + "minutes_listened": "सुनिएका मिनेटहरू", + "streamed_songs": "स्ट्रीम गरिएका गीतहरू", + "count_streams": "{count} स्ट्रिम", + "owned_by_you": "तपाईंले स्वामित्व गरेको", + "copied_shareurl_to_clipboard": "{shareUrl} क्लिपबोर्डमा कपी गरियो", + "spotify_hipotetical_calculation": "*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n$0.003 देखि $0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।" } \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index aa5c846d..5e22446d 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -326,5 +326,64 @@ "add_library_location": "Toevoegen aan bibliotheek", "remove_library_location": "Verwijderen uit bibliotheek", "local_tab": "Lokaal", - "stats": "Statistieken" + "stats": "Statistieken", + "and_n_more": "en {count} meer", + "recently_played": "Onlangs afgespeeld", + "browse_more": "Meer bekijken", + "no_title": "Geen titel", + "not_playing": "Niet aan het afspelen", + "epic_failure": "Epische mislukking!", + "added_num_tracks_to_queue": "{tracks_length} nummers aan de wachtrij toegevoegd", + "spotube_has_an_update": "Spotube heeft een update", + "download_now": "Nu downloaden", + "nightly_version": "Spotube Nightly {nightlyBuildNum} is uitgebracht", + "release_version": "Spotube v{version} is uitgebracht", + "read_the_latest": "Lees de nieuwste ", + "release_notes": "release-opmerkingen", + "pick_color_scheme": "Kies kleurenschema", + "save": "Opslaan", + "choose_the_device": "Kies het apparaat:", + "multiple_device_connected": "Er zijn meerdere apparaten verbonden.\nKies het apparaat waarop je deze actie wilt uitvoeren", + "nothing_found": "Niets gevonden", + "the_box_is_empty": "De doos is leeg", + "top_artists": "Topartiesten", + "top_albums": "Topalbums", + "this_week": "Deze week", + "this_month": "Deze maand", + "last_6_months": "Laatste 6 maanden", + "this_year": "Dit jaar", + "last_2_years": "Laatste 2 jaar", + "all_time": "All time", + "powered_by_provider": "Aangedreven door {providerName}", + "email": "E-mail", + "profile_followers": "Volgers", + "birthday": "Verjaardag", + "subscription": "Abonnement", + "not_born": "Niet geboren", + "hacker": "Hacker", + "profile": "Profiel", + "no_name": "Geen naam", + "edit": "Bewerken", + "user_profile": "Gebruikersprofiel", + "count_plays": "{count} afspeelbeurten", + "streaming_fees_hypothetical": "*Dit is berekend op basis van Spotify's uitbetaling per stream\nvan $0.003 tot $0.005. Dit is een hypothetische\nberekening om gebruikers inzicht te geven in hoeveel ze\naan de artiesten zouden hebben betaald als ze hun lied op Spotify zouden hebben beluisterd.", + "count_mins": "{minutes} min", + "summary_minutes": "minuten", + "summary_listened_to_music": "Beluisterde muziek", + "summary_songs": "nummers", + "summary_streamed_overall": "Totaal gestreamd", + "summary_owed_to_artists": "Te betalen aan artiesten\ndeze maand", + "summary_artists": "van de artiest", + "summary_music_reached_you": "Muziek heeft je bereikt", + "summary_full_albums": "volledige albums", + "summary_got_your_love": "Kreeg je liefde", + "summary_playlists": "afspeellijsten", + "summary_were_on_repeat": "Was op herhaling", + "total_money": "Totaal {money}", + "minutes_listened": "Luistertijd", + "streamed_songs": "Gestreamde nummers", + "count_streams": "{count} streams", + "owned_by_you": "Bezit door jou", + "copied_shareurl_to_clipboard": "{shareUrl} gekopieerd naar klembord", + "spotify_hipotetical_calculation": "*Dit is berekend op basis van Spotify's betaling per stream\nvan $0.003 tot $0.005. Dit is een hypothetische\nberekening om de gebruiker inzicht te geven in hoeveel ze\naan de artiesten zouden hebben betaald als ze hun liedjes op Spotify\nzouden luisteren." } \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 2c4e8369..06449ad9 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -325,5 +325,64 @@ "add_library_location": "Dodaj do biblioteki", "remove_library_location": "Usuń z biblioteki", "local_tab": "Lokalny", - "stats": "Statystyki" + "stats": "Statystyki", + "and_n_more": "i {count} więcej", + "recently_played": "Ostatnio odtwarzane", + "browse_more": "Zobacz więcej", + "no_title": "Brak tytułu", + "not_playing": "Nie odtwarzane", + "epic_failure": "Epicka porażka!", + "added_num_tracks_to_queue": "Dodano {tracks_length} utworów do kolejki", + "spotube_has_an_update": "Spotube ma aktualizację", + "download_now": "Pobierz teraz", + "nightly_version": "Spotube Nightly {nightlyBuildNum} został wydany", + "release_version": "Spotube v{version} został wydany", + "read_the_latest": "Przeczytaj najnowsze ", + "release_notes": "notatki o wersji", + "pick_color_scheme": "Wybierz schemat kolorów", + "save": "Zapisz", + "choose_the_device": "Wybierz urządzenie:", + "multiple_device_connected": "Jest wiele urządzeń podłączonych.\nWybierz urządzenie, na którym chcesz wykonać tę akcję", + "nothing_found": "Nic nie znaleziono", + "the_box_is_empty": "Pudełko jest puste", + "top_artists": "Najlepsi artyści", + "top_albums": "Najlepsze albumy", + "this_week": "W tym tygodniu", + "this_month": "W tym miesiącu", + "last_6_months": "Ostatnie 6 miesięcy", + "this_year": "W tym roku", + "last_2_years": "Ostatnie 2 lata", + "all_time": "Wszystkie czasy", + "powered_by_provider": "Napędzane przez {providerName}", + "email": "E-mail", + "profile_followers": "Obserwujący", + "birthday": "Data urodzenia", + "subscription": "Subskrypcja", + "not_born": "Nie urodzony", + "hacker": "Haker", + "profile": "Profil", + "no_name": "Brak nazwy", + "edit": "Edytuj", + "user_profile": "Profil użytkownika", + "count_plays": "{count} odtworzeń", + "streaming_fees_hypothetical": "*Obliczone na podstawie wypłaty Spotify za stream\nod $0.003 do $0.005. Jest to hipotetyczne\nobliczenie, które ma na celu pokazanie, ile\nużytkownik zapłaciłby artystom, gdyby odsłuchał\ntych utworów na Spotify.", + "count_mins": "{minutes} min", + "summary_minutes": "minuty", + "summary_listened_to_music": "Słuchana muzyka", + "summary_songs": "utwory", + "summary_streamed_overall": "Ogółem streamowane", + "summary_owed_to_artists": "Do zapłaty artystom\nw tym miesiącu", + "summary_artists": "artystów", + "summary_music_reached_you": "Muzyka dotarła do Ciebie", + "summary_full_albums": "pełne albumy", + "summary_got_your_love": "Otrzymał Twoją miłość", + "summary_playlists": "playlisty", + "summary_were_on_repeat": "Były na powtarzaniu", + "total_money": "Łącznie {money}", + "minutes_listened": "Minuty odsłuchane", + "streamed_songs": "Strumieniowane utwory", + "count_streams": "{count} strumieni", + "owned_by_you": "Własność Twoja", + "copied_shareurl_to_clipboard": "{shareUrl} skopiowano do schowka", + "spotify_hipotetical_calculation": "*Obliczone na podstawie płatności Spotify za strumień\nw zakresie od $0.003 do $0.005. Jest to hipotetyczne\nobliczenie mające na celu pokazanie użytkownikowi, ile\nzapłaciliby artystom, gdyby słuchali ich utworów na Spotify." } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 88cf5cb3..7231d15a 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -325,5 +325,64 @@ "add_library_location": "Adicionar à biblioteca", "remove_library_location": "Remover da biblioteca", "local_tab": "Local", - "stats": "Estatísticas" + "stats": "Estatísticas", + "and_n_more": "e {count} mais", + "recently_played": "Reproduzido Recentemente", + "browse_more": "Ver Mais", + "no_title": "Sem Título", + "not_playing": "Não está a reproduzir", + "epic_failure": "Fracasso épico!", + "added_num_tracks_to_queue": "Adicionados {tracks_length} faixas à fila", + "spotube_has_an_update": "Spotube tem uma atualização", + "download_now": "Baixar Agora", + "nightly_version": "Spotube Nightly {nightlyBuildNum} foi lançado", + "release_version": "Spotube v{version} foi lançado", + "read_the_latest": "Leia o mais recente ", + "release_notes": "notas de versão", + "pick_color_scheme": "Escolha o esquema de cores", + "save": "Salvar", + "choose_the_device": "Escolha o dispositivo:", + "multiple_device_connected": "Há vários dispositivos conectados.\nEscolha o dispositivo no qual deseja executar esta ação", + "nothing_found": "Nada encontrado", + "the_box_is_empty": "A caixa está vazia", + "top_artists": "Principais Artistas", + "top_albums": "Principais Álbuns", + "this_week": "Esta semana", + "this_month": "Este mês", + "last_6_months": "Últimos 6 meses", + "this_year": "Este ano", + "last_2_years": "Últimos 2 anos", + "all_time": "De todos os tempos", + "powered_by_provider": "Desenvolvido por {providerName}", + "email": "E-mail", + "profile_followers": "Seguidores", + "birthday": "Aniversário", + "subscription": "Assinatura", + "not_born": "Não nascido", + "hacker": "Hacker", + "profile": "Perfil", + "no_name": "Sem Nome", + "edit": "Editar", + "user_profile": "Perfil do Usuário", + "count_plays": "{count} reproduzidos", + "streaming_fees_hypothetical": "*Calculado com base no pagamento por stream do Spotify\nque varia de $0.003 a $0.005. Isso é um cálculo hipotético\npara fornecer uma visão ao usuário sobre quanto eles\nteriam pago aos artistas se estivessem ouvindo\no seu som no Spotify.", + "count_mins": "{minutes} min", + "summary_minutes": "minutos", + "summary_listened_to_music": "Música ouvida", + "summary_songs": "faixas", + "summary_streamed_overall": "Total de streams", + "summary_owed_to_artists": "Devido aos artistas\neste mês", + "summary_artists": "artista", + "summary_music_reached_you": "A música chegou até você", + "summary_full_albums": "álbuns completos", + "summary_got_your_love": "Recebeu seu amor", + "summary_playlists": "playlists", + "summary_were_on_repeat": "Estavam em repetição", + "total_money": "Total {money}", + "minutes_listened": "Minutos ouvidos", + "streamed_songs": "Músicas transmitidas", + "count_streams": "{count} streams", + "owned_by_you": "De sua propriedade", + "copied_shareurl_to_clipboard": "{shareUrl} copiado para a área de transferência", + "spotify_hipotetical_calculation": "*Isso é calculado com base no pagamento por stream do Spotify\nque varia de $0.003 a $0.005. Esta é uma cálculo hipotético\npara dar ao usuário uma visão de quanto teriam pago aos artistas\nse eles ouvissem suas músicas no Spotify." } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 2e0aa77b..7cffb42a 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -184,7 +184,7 @@ "success_emoji": "Успешно🥳", "success_message": "Теперь вы успешно вошли в свою учетную запись Spotify. Отличная работа, приятель!", "step_4": "Шаг 4", - "step_4_steps": "Вставьте скопированное значение \"sp_dc\", + "step_4_steps": "Вставьте скопированное значение \"sp_dc\"", "something_went_wrong": "Что-то пошло не так", "piped_instance": "Экземпляр сервера Piped", "piped_description": "Серверный экземпляр Piped для сопоставления треков", @@ -324,5 +324,65 @@ "connect_client_alert": "Вас контролирует {client}", "this_device": "Это устройство", "remote": "Дистанционное управление", - "stats": "Статистика" + "stats": "Статистика", + "update_playlist": "Обновить плейлист", + "and_n_more": "и {count} еще", + "recently_played": "Недавно воспроизведено", + "browse_more": "Посмотреть больше", + "no_title": "Без названия", + "not_playing": "Не воспроизводится", + "epic_failure": "Эпическое фиаско!", + "added_num_tracks_to_queue": "Добавлено {tracks_length} треков в очередь", + "spotube_has_an_update": "В Spotube доступно обновление", + "download_now": "Скачать сейчас", + "nightly_version": "Spotube Nightly {nightlyBuildNum} выпущен", + "release_version": "Spotube v{version} выпущен", + "read_the_latest": "Читать последние ", + "release_notes": "заметки о версии", + "pick_color_scheme": "Выберите цветовую схему", + "save": "Сохранить", + "choose_the_device": "Выберите устройство:", + "multiple_device_connected": "Подключено несколько устройств.\nВыберите устройство, на котором вы хотите выполнить это действие", + "nothing_found": "Ничего не найдено", + "the_box_is_empty": "Коробка пуста", + "top_artists": "Лучшие артисты", + "top_albums": "Лучшие альбомы", + "this_week": "На этой неделе", + "this_month": "В этом месяце", + "last_6_months": "Последние 6 месяцев", + "this_year": "В этом году", + "last_2_years": "Последние 2 года", + "all_time": "Все время", + "powered_by_provider": "При поддержке {providerName}", + "email": "Электронная почта", + "profile_followers": "Подписчики", + "birthday": "День рождения", + "subscription": "Подписка", + "not_born": "Не рожден", + "hacker": "Хакер", + "profile": "Профиль", + "no_name": "Без имени", + "edit": "Редактировать", + "user_profile": "Профиль пользователя", + "count_plays": "{count} воспроизведений", + "streaming_fees_hypothetical": "*Рассчитано на основе выплат Spotify за стрим\nот $0.003 до $0.005. Это гипотетический\nрасчет, чтобы показать пользователю, сколько бы он\nзаплатил артистам, если бы слушал их песни на Spotify.", + "count_mins": "{minutes} мин", + "summary_minutes": "минуты", + "summary_listened_to_music": "Слушанная музыка", + "summary_songs": "песни", + "summary_streamed_overall": "Всего стримов", + "summary_owed_to_artists": "К выплате артистам\nв этом месяце", + "summary_artists": "артиста", + "summary_music_reached_you": "Музыка дошла до вас", + "summary_full_albums": "полные альбомы", + "summary_got_your_love": "Получил вашу любовь", + "summary_playlists": "плейлисты", + "summary_were_on_repeat": "Были на повторе", + "total_money": "Всего {money}", + "minutes_listened": "Минут прослушивания", + "streamed_songs": "Стримленные песни", + "count_streams": "{count} стримов", + "owned_by_you": "Ваша собственность", + "copied_shareurl_to_clipboard": "{shareUrl} скопировано в буфер обмена", + "spotify_hipotetical_calculation": "*Это рассчитано на основе выплат Spotify за стрим\nот $0.003 до $0.005. Это гипотетический расчет,\nчтобы дать пользователю представление о том, сколько бы он\nзаплатил артистам, если бы слушал их песни на Spotify." } \ No newline at end of file diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 60ced74b..3cac73f7 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -326,5 +326,64 @@ "add_library_location": "เพิ่มในห้องสมุด", "remove_library_location": "ลบออกจากห้องสมุด", "local_tab": "ท้องถิ่น", - "stats": "สถิติ" + "stats": "สถิติ", + "and_n_more": "และ {count} อีก", + "recently_played": "เพลงที่เพิ่งเล่น", + "browse_more": "ดูเพิ่มเติม", + "no_title": "ไม่มีชื่อ", + "not_playing": "ไม่เล่น", + "epic_failure": "ล้มเหลวอย่างยิ่ง!", + "added_num_tracks_to_queue": "เพิ่ม {tracks_length} เพลงในคิว", + "spotube_has_an_update": "Spotube มีการอัปเดต", + "download_now": "ดาวน์โหลดตอนนี้", + "nightly_version": "Spotube Nightly {nightlyBuildNum} ได้รับการปล่อยออกมา", + "release_version": "Spotube v{version} ได้รับการปล่อยออกมา", + "read_the_latest": "อ่านข่าวสารล่าสุด ", + "release_notes": "บันทึกการปล่อย", + "pick_color_scheme": "เลือกธีมสี", + "save": "บันทึก", + "choose_the_device": "เลือกอุปกรณ์:", + "multiple_device_connected": "มีอุปกรณ์เชื่อมต่อหลายเครื่อง\nเลือกอุปกรณ์ที่คุณต้องการให้การดำเนินการนี้เกิดขึ้น", + "nothing_found": "ไม่พบข้อมูล", + "the_box_is_empty": "กล่องว่างเปล่า", + "top_artists": "ศิลปินยอดนิยม", + "top_albums": "อัลบั้มยอดนิยม", + "this_week": "สัปดาห์นี้", + "this_month": "เดือนนี้", + "last_6_months": "6 เดือนที่ผ่านมา", + "this_year": "ปีนี้", + "last_2_years": "2 ปีที่ผ่านมา", + "all_time": "ตลอดกาล", + "powered_by_provider": "ขับเคลื่อนโดย {providerName}", + "email": "อีเมล", + "profile_followers": "ผู้ติดตาม", + "birthday": "วันเกิด", + "subscription": "การสมัครสมาชิก", + "not_born": "ยังไม่เกิด", + "hacker": "แฮ็กเกอร์", + "profile": "โปรไฟล์", + "no_name": "ไม่มีชื่อ", + "edit": "แก้ไข", + "user_profile": "โปรไฟล์ผู้ใช้", + "count_plays": "{count} การเล่น", + "streaming_fees_hypothetical": "*คำนวณจากการจ่ายเงินต่อการสตรีมของ Spotify\nระหว่าง $0.003 ถึง $0.005 นี่เป็นการคำนวณสมมุติ\nเพื่อให้ข้อมูลแก่ผู้ใช้เกี่ยวกับจำนวนเงินที่พวกเขา\nอาจจะจ่ายให้กับศิลปินหากพวกเขาฟังเพลงของพวกเขาใน Spotify", + "count_mins": "{minutes} นาที", + "summary_minutes": "นาที", + "summary_listened_to_music": "ฟังเพลง", + "summary_songs": "เพลง", + "summary_streamed_overall": "สตรีมทั้งหมด", + "summary_owed_to_artists": "ค้างชำระให้ศิลปิน\nในเดือนนี้", + "summary_artists": "ศิลปิน", + "summary_music_reached_you": "เพลงมาถึงคุณ", + "summary_full_albums": "อัลบั้มเต็ม", + "summary_got_your_love": "ได้รับความรักของคุณ", + "summary_playlists": "เพลย์ลิสต์", + "summary_were_on_repeat": "อยู่ในโหมดซ้ำ", + "total_money": "รวม {money}", + "minutes_listened": "เวลาที่ฟัง", + "streamed_songs": "เพลงที่สตรีม", + "count_streams": "{count} สตรีม", + "owned_by_you": "เป็นเจ้าของโดยคุณ", + "copied_shareurl_to_clipboard": "{shareUrl} คัดลอกไปที่คลิปบอร์ดแล้ว", + "spotify_hipotetical_calculation": "*คำนวณตามการจ่ายต่อสตรีมของ Spotify\nซึ่งอยู่ในช่วง $0.003 ถึง $0.005 นี่เป็นการคำนวณสมมุติ\nเพื่อให้ผู้ใช้ทราบว่าพวกเขาจะจ่ายเงินให้ศิลปินเท่าไหร่\nหากพวกเขาฟังเพลงของพวกเขาใน Spotify." } \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index b329cfa7..b5a0ec1e 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -325,5 +325,64 @@ "add_library_location": "Kütüphaneye ekle", "remove_library_location": "Kütüphaneden çıkar", "local_tab": "Yerel", - "stats": "İstatistikler" + "stats": "İstatistikler", + "and_n_more": "ve {count} daha", + "recently_played": "Son Çalınanlar", + "browse_more": "Daha Fazla Göz At", + "no_title": "Başlık Yok", + "not_playing": "Çalmıyor", + "epic_failure": "Efsanevi başarısızlık!", + "added_num_tracks_to_queue": "{tracks_length} şarkı sıraya eklendi", + "spotube_has_an_update": "Spotube bir güncelleme aldı", + "download_now": "Şimdi İndir", + "nightly_version": "Spotube Nightly {nightlyBuildNum} yayımlandı", + "release_version": "Spotube v{version} yayımlandı", + "read_the_latest": "Son haberleri oku", + "release_notes": "sürüm notları", + "pick_color_scheme": "Renk şeması seç", + "save": "Kaydet", + "choose_the_device": "Cihazı seçin:", + "multiple_device_connected": "Birden fazla cihaz bağlı.\nBu işlemi gerçekleştirmek istediğiniz cihazı seçin", + "nothing_found": "Hiçbir şey bulunamadı", + "the_box_is_empty": "Kutu boş", + "top_artists": "En İyi Sanatçılar", + "top_albums": "En İyi Albümler", + "this_week": "Bu hafta", + "this_month": "Bu ay", + "last_6_months": "Son 6 ay", + "this_year": "Bu yıl", + "last_2_years": "Son 2 yıl", + "all_time": "Tüm zamanlar", + "powered_by_provider": "{providerName} tarafından desteklenmektedir", + "email": "E-posta", + "profile_followers": "Takipçiler", + "birthday": "Doğum Günü", + "subscription": "Abonelik", + "not_born": "Henüz doğmadı", + "hacker": "Hacker", + "profile": "Profil", + "no_name": "İsim Yok", + "edit": "Düzenle", + "user_profile": "Kullanıcı Profili", + "count_plays": "{count} çalma", + "streaming_fees_hypothetical": "*Spotify'ın akış başına ödeme miktarına\n$0.003 ile $0.005 arasında hesaplanmıştır. Bu, kullanıcıya\nSpotify'da şarkılarını dinlerse sanatçılara ne kadar ödeme\nyapmış olabileceğini göstermek için hipotetik bir hesaplamadır.", + "count_mins": "{minutes} dk", + "summary_minutes": "dakika", + "summary_listened_to_music": "Dinlenen müzik", + "summary_songs": "şarkılar", + "summary_streamed_overall": "Genel olarak akış", + "summary_owed_to_artists": "Sanatçılara borç\nbu ay", + "summary_artists": "sanatçının", + "summary_music_reached_you": "Müzik sana ulaştı", + "summary_full_albums": "tam albümler", + "summary_got_your_love": "Sevgini aldı", + "summary_playlists": "çalma listeleri", + "summary_were_on_repeat": "Tekrarda vardı", + "total_money": "Toplam {money}", + "minutes_listened": "Dinlenilen Dakikalar", + "streamed_songs": "Yayınlanan Şarkılar", + "count_streams": "{count} yayın", + "owned_by_you": "Sahip olduğunuz", + "copied_shareurl_to_clipboard": "{shareUrl} panoya kopyalandı", + "spotify_hipotetical_calculation": "*Bu, Spotify'ın her yayın başına ödemenin\n$0.003 ile $0.005 arasında olduğu varsayımıyla hesaplanmıştır. Bu\nhipotetik bir hesaplamadır, kullanıcıya şarkılarını Spotify'da dinlediklerinde\nsanatçılara ne kadar ödeme yapacaklarını gösterir." } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index d056524e..013a64b7 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -325,5 +325,64 @@ "add_library_location": "Додати до бібліотеки", "remove_library_location": "Видалити з бібліотеки", "local_tab": "Місцевий", - "stats": "Статистика" + "stats": "Статистика", + "and_n_more": "і {count} більше", + "recently_played": "Нещодавно Відтворене", + "browse_more": "Переглянути Більше", + "no_title": "Без Назви", + "not_playing": "Не Відтворюється", + "epic_failure": "Епічний провал!", + "added_num_tracks_to_queue": "Додано {tracks_length} треків до черги", + "spotube_has_an_update": "Spotube має оновлення", + "download_now": "Завантажити Зараз", + "nightly_version": "Spotube Nightly {nightlyBuildNum} було випущено", + "release_version": "Spotube v{version} було випущено", + "read_the_latest": "Читати останні новини", + "release_notes": "ноти про випуск", + "pick_color_scheme": "Оберіть кольорову схему", + "save": "Зберегти", + "choose_the_device": "Виберіть пристрій:", + "multiple_device_connected": "Підключено кілька пристроїв.\nВиберіть пристрій, на якому ви хочете виконати цю дію", + "nothing_found": "Нічого не знайдено", + "the_box_is_empty": "Коробка порожня", + "top_artists": "Топ Артисти", + "top_albums": "Топ Альбоми", + "this_week": "Цього тижня", + "this_month": "Цього місяця", + "last_6_months": "Останні 6 місяців", + "this_year": "Цього року", + "last_2_years": "Останні 2 роки", + "all_time": "Усі часи", + "powered_by_provider": "Забезпечено {providerName}", + "email": "Електронна пошта", + "profile_followers": "Підписники", + "birthday": "День народження", + "subscription": "Підписка", + "not_born": "Ще не народжений", + "hacker": "Хакер", + "profile": "Профіль", + "no_name": "Без імені", + "edit": "Редагувати", + "user_profile": "Профіль користувача", + "count_plays": "{count} відтворень", + "streaming_fees_hypothetical": "*Розраховано на основі виплат Spotify за стримінг\nвід $0.003 до $0.005. Це гіпотетичний\nрозрахунок, щоб дати уявлення користувачу про те, скільки б він\nзаплатив артистам, якби слухав їхні пісні на Spotify.", + "count_mins": "{minutes} хв", + "summary_minutes": "хвилини", + "summary_listened_to_music": "Прослухана музика", + "summary_songs": "пісні", + "summary_streamed_overall": "Загалом стримів", + "summary_owed_to_artists": "Заборгованість артистам\nцього місяця", + "summary_artists": "артистів", + "summary_music_reached_you": "Музика досягла вас", + "summary_full_albums": "повні альбоми", + "summary_got_your_love": "Отримав вашу любов", + "summary_playlists": "плейлисти", + "summary_were_on_repeat": "Були на повторі", + "total_money": "Загалом {money}", + "minutes_listened": "Хвилини прослуховування", + "streamed_songs": "Стримлені пісні", + "count_streams": "{count} стримів", + "owned_by_you": "Ваша власність", + "copied_shareurl_to_clipboard": "{shareUrl} скопійовано в буфер обміну", + "spotify_hipotetical_calculation": "*Це розраховано на основі виплат Spotify за стрім\nвід $0.003 до $0.005. Це гіпотетичний розрахунок,\nщоб дати користувачеві уявлення про те, скільки б він заплатив\nартистам, якби слухав їхні пісні на Spotify." } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 6bbd6cb6..5791793e 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -325,5 +325,64 @@ "add_library_location": "Thêm vào thư viện", "remove_library_location": "Xóa khỏi thư viện", "local_tab": "Địa phương", - "stats": "Thống kê" + "stats": "Thống kê", + "and_n_more": "và {count} cái khác", + "recently_played": "Gần đây đã phát", + "browse_more": "Xem thêm", + "no_title": "Không có tiêu đề", + "not_playing": "Không phát", + "epic_failure": "Thất bại hoàn toàn!", + "added_num_tracks_to_queue": "Đã thêm {tracks_length} bài hát vào danh sách phát", + "spotube_has_an_update": "Spotube có bản cập nhật", + "download_now": "Tải về ngay", + "nightly_version": "Spotube Nightly {nightlyBuildNum} đã được phát hành", + "release_version": "Spotube v{version} đã được phát hành", + "read_the_latest": "Đọc tin mới nhất", + "release_notes": "ghi chú phát hành", + "pick_color_scheme": "Chọn chủ đề màu sắc", + "save": "Lưu", + "choose_the_device": "Chọn thiết bị:", + "multiple_device_connected": "Có nhiều thiết bị kết nối.\nChọn thiết bị mà bạn muốn thực hiện hành động này", + "nothing_found": "Không tìm thấy gì", + "the_box_is_empty": "Hộp trống", + "top_artists": "Những Nghệ Sĩ Hàng Đầu", + "top_albums": "Những Album Hàng Đầu", + "this_week": "Tuần này", + "this_month": "Tháng này", + "last_6_months": "6 tháng qua", + "this_year": "Năm nay", + "last_2_years": "2 năm qua", + "all_time": "Mọi thời đại", + "powered_by_provider": "Cung cấp bởi {providerName}", + "email": "Email", + "profile_followers": "Người theo dõi", + "birthday": "Ngày sinh", + "subscription": "Gói cước", + "not_born": "Chưa sinh", + "hacker": "Tin tặc", + "profile": "Hồ sơ", + "no_name": "Không có tên", + "edit": "Chỉnh sửa", + "user_profile": "Hồ sơ người dùng", + "count_plays": "{count} lần phát", + "streaming_fees_hypothetical": "*Tính toán dựa trên thanh toán của Spotify cho mỗi lần phát\ntừ $0.003 đến $0.005. Đây là một tính toán giả định để\ngive người dùng cái nhìn về số tiền họ sẽ chi trả cho các nghệ sĩ nếu họ nghe\nbài hát của họ trên Spotify.", + "count_mins": "{minutes} phút", + "summary_minutes": "phút", + "summary_listened_to_music": "Đã nghe nhạc", + "summary_songs": "bài hát", + "summary_streamed_overall": "Stream tổng cộng", + "summary_owed_to_artists": "Nợ nghệ sĩ\ntrong tháng này", + "summary_artists": "nghệ sĩ", + "summary_music_reached_you": "Âm nhạc đã đến với bạn", + "summary_full_albums": "album đầy đủ", + "summary_got_your_love": "Nhận được tình yêu của bạn", + "summary_playlists": "danh sách phát", + "summary_were_on_repeat": "Đã được phát lại", + "total_money": "Tổng cộng {money}", + "minutes_listened": "Thời gian nghe", + "streamed_songs": "Bài hát đã phát", + "count_streams": "{count} lượt phát", + "owned_by_you": "Thuộc sở hữu của bạn", + "copied_shareurl_to_clipboard": "{shareUrl} đã sao chép vào bảng tạm", + "spotify_hipotetical_calculation": "*Được tính toán dựa trên khoản thanh toán của Spotify cho mỗi lượt phát\ntừ $0.003 đến $0.005. Đây là một tính toán giả định để\ncung cấp cho người dùng cái nhìn về số tiền họ sẽ phải trả\ncho các nghệ sĩ nếu họ nghe bài hát của họ trên Spotify." } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index b145f97b..91447213 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -325,5 +325,64 @@ "add_library_location": "添加到图书馆", "remove_library_location": "从图书馆中删除", "local_tab": "本地", - "stats": "统计" + "stats": "统计", + "and_n_more": "和 {count} 更多", + "recently_played": "最近播放", + "browse_more": "浏览更多", + "no_title": "没有标题", + "not_playing": "未播放", + "epic_failure": "史诗级失败!", + "added_num_tracks_to_queue": "已将 {tracks_length} 首曲目添加到队列", + "spotube_has_an_update": "Spotube 有更新", + "download_now": "立即下载", + "nightly_version": "Spotube Nightly {nightlyBuildNum} 已发布", + "release_version": "Spotube v{version} 已发布", + "read_the_latest": "阅读最新", + "release_notes": "版本说明", + "pick_color_scheme": "选择配色方案", + "save": "保存", + "choose_the_device": "选择设备:", + "multiple_device_connected": "已连接多个设备。\n选择您希望执行此操作的设备", + "nothing_found": "未找到任何内容", + "the_box_is_empty": "箱子为空", + "top_artists": "热门艺术家", + "top_albums": "热门专辑", + "this_week": "本周", + "this_month": "本月", + "last_6_months": "过去6个月", + "this_year": "今年", + "last_2_years": "过去2年", + "all_time": "所有时间", + "powered_by_provider": "由 {providerName} 提供支持", + "email": "电子邮件", + "profile_followers": "关注者", + "birthday": "生日", + "subscription": "订阅", + "not_born": "尚未出生", + "hacker": "黑客", + "profile": "个人资料", + "no_name": "无名", + "edit": "编辑", + "user_profile": "用户资料", + "count_plays": "{count} 次播放", + "streaming_fees_hypothetical": "*基于 Spotify 每次播放的支付金额\n从 $0.003 到 $0.005 计算。这是一个假设性的\n计算,旨在让用户了解如果他们在 Spotify 上收听\n这些歌曲,可能会付给艺术家的金额。", + "count_mins": "{minutes} 分钟", + "summary_minutes": "分钟", + "summary_listened_to_music": "听音乐", + "summary_songs": "歌曲", + "summary_streamed_overall": "总体流媒体", + "summary_owed_to_artists": "本月欠艺术家的", + "summary_artists": "艺术家的", + "summary_music_reached_you": "音乐触及了你", + "summary_full_albums": "完整专辑", + "summary_got_your_love": "获得了你的爱", + "summary_playlists": "播放列表", + "summary_were_on_repeat": "已重复播放", + "total_money": "总计 {money}", + "minutes_listened": "听的分钟数", + "streamed_songs": "已流媒体歌曲", + "count_streams": "{count} 次流媒体", + "owned_by_you": "由您拥有", + "copied_shareurl_to_clipboard": "{shareUrl} 已复制到剪贴板", + "spotify_hipotetical_calculation": "*根据 Spotify 每次流媒体的支付金额\n$0.003 到 $0.005 进行计算。这是一个假设性的\n计算,用于给用户了解他们如果在 Spotify 上\n收听歌曲会支付给艺术家的金额。" } \ No newline at end of file diff --git a/lib/modules/artist/artist_card.dart b/lib/modules/artist/artist_card.dart index 896271f2..add2608d 100644 --- a/lib/modules/artist/artist_card.dart +++ b/lib/modules/artist/artist_card.dart @@ -46,9 +46,9 @@ class ArtistCard extends HookConsumerWidget { width: size, margin: const EdgeInsets.symmetric(vertical: 5), child: Material( - shadowColor: theme.colorScheme.background, + shadowColor: theme.colorScheme.surface, color: Color.lerp( - theme.colorScheme.surfaceVariant, + theme.colorScheme.surfaceContainerHighest, theme.colorScheme.surface, useBrightnessValue(.9, .7), ), diff --git a/lib/modules/home/sections/friends/friend_item.dart b/lib/modules/home/sections/friends/friend_item.dart index bb97af04..773a4a8c 100644 --- a/lib/modules/home/sections/friends/friend_item.dart +++ b/lib/modules/home/sections/friends/friend_item.dart @@ -30,7 +30,7 @@ class FriendItem extends HookConsumerWidget { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: colorScheme.surfaceVariant.withOpacity(0.3), + color: colorScheme.surfaceContainerHighest.withOpacity(0.3), borderRadius: BorderRadius.circular(15), ), constraints: const BoxConstraints( diff --git a/lib/modules/home/sections/genres.dart b/lib/modules/home/sections/genres.dart index 3e12e5e9..5f2dfa5e 100644 --- a/lib/modules/home/sections/genres.dart +++ b/lib/modules/home/sections/genres.dart @@ -134,7 +134,7 @@ class HomeGenresSection extends HookConsumerWidget { child: Ink( decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), - color: colorScheme.surfaceVariant, + color: colorScheme.surfaceContainerHighest, gradient: categoriesQuery.isLoading ? null : gradient, ), padding: const EdgeInsets.symmetric(horizontal: 16), diff --git a/lib/modules/library/local_folder/local_folder_item.dart b/lib/modules/library/local_folder/local_folder_item.dart index a5831fc2..02e47a53 100644 --- a/lib/modules/library/local_folder/local_folder_item.dart +++ b/lib/modules/library/local_folder/local_folder_item.dart @@ -71,7 +71,7 @@ class LocalFolderItem extends HookConsumerWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Color.lerp( - colorScheme.surfaceVariant, + colorScheme.surfaceContainerHighest, colorScheme.surface, lerpValue, ), diff --git a/lib/modules/library/playlist_generate/multi_select_field.dart b/lib/modules/library/playlist_generate/multi_select_field.dart index 8cafe02f..7118d57d 100644 --- a/lib/modules/library/playlist_generate/multi_select_field.dart +++ b/lib/modules/library/playlist_generate/multi_select_field.dart @@ -71,7 +71,7 @@ class MultiSelectField extends HookWidget { : theme.colorScheme.onSurface.withOpacity(0.1), ), ), - mouseCursor: MaterialStateMouseCursor.textable, + mouseCursor: WidgetStateMouseCursor.textable, onPressed: !enabled ? null : () async { diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index 538af685..3202eeda 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -278,7 +278,7 @@ class PlayerView extends HookConsumerWidget { const SizedBox(height: 10), PlayerControls(palette: palette), const SizedBox(height: 25), - PlayerActions( + const PlayerActions( mainAxisAlignment: MainAxisAlignment.spaceEvenly, showQueue: false, ), diff --git a/lib/modules/player/player_queue.dart b/lib/modules/player/player_queue.dart index 2431d82e..369b95d2 100644 --- a/lib/modules/player/player_queue.dart +++ b/lib/modules/player/player_queue.dart @@ -121,7 +121,8 @@ class PlayerQueue extends HookConsumerWidget { top: 5.0, ), decoration: BoxDecoration( - color: theme.colorScheme.surfaceVariant.withOpacity(0.5), + color: + theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), borderRadius: borderRadius, ), child: CallbackShortcuts( diff --git a/lib/modules/player/sibling_tracks_sheet.dart b/lib/modules/player/sibling_tracks_sheet.dart index 092d631f..b58a5894 100644 --- a/lib/modules/player/sibling_tracks_sheet.dart +++ b/lib/modules/player/sibling_tracks_sheet.dart @@ -211,7 +211,8 @@ class SiblingTracksSheet extends HookConsumerWidget { : mediaQuery.size.height * .6, decoration: BoxDecoration( borderRadius: borderRadius, - color: theme.colorScheme.surfaceVariant.withOpacity(.5), + color: + theme.colorScheme.surfaceContainerHighest.withOpacity(.5), ), child: Scaffold( backgroundColor: Colors.transparent, diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart index 23904aef..7f37c472 100644 --- a/lib/modules/root/bottom_player.dart +++ b/lib/modules/root/bottom_player.dart @@ -48,7 +48,7 @@ class BottomPlayer extends HookConsumerWidget { ); final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceVariant; + final bg = theme.colorScheme.surfaceContainerHighest; final bgColor = useBrightnessValue( Color.lerp(bg, Colors.white, 0.7), @@ -77,10 +77,10 @@ class BottomPlayer extends HookConsumerWidget { child: PlayerTrackDetails(track: playlist.activeTrack), ), // controls - Flexible( + const Flexible( flex: 3, child: Padding( - padding: const EdgeInsets.only(top: 5), + padding: EdgeInsets.only(top: 5), child: PlayerControls(), ), ), diff --git a/lib/modules/root/sidebar.dart b/lib/modules/root/sidebar.dart index ef735798..8f7f495c 100644 --- a/lib/modules/root/sidebar.dart +++ b/lib/modules/root/sidebar.dart @@ -72,7 +72,7 @@ class Sidebar extends HookConsumerWidget { ); final theme = Theme.of(context); - final bg = theme.colorScheme.surfaceVariant; + final bg = theme.colorScheme.surfaceContainerHighest; final bgColor = useBrightnessValue( Color.lerp(bg, Colors.white, 0.7), diff --git a/lib/modules/root/spotube_navigation_bar.dart b/lib/modules/root/spotube_navigation_bar.dart index c624a40c..978891b8 100644 --- a/lib/modules/root/spotube_navigation_bar.dart +++ b/lib/modules/root/spotube_navigation_bar.dart @@ -69,7 +69,7 @@ class SpotubeNavigationBar extends HookConsumerWidget { backgroundColor: theme.colorScheme.secondaryContainer.withOpacity(0.72), buttonBackgroundColor: buttonColor, - color: theme.colorScheme.background, + color: theme.colorScheme.surface, height: panelHeight, animationDuration: const Duration(milliseconds: 350), items: navbarTileList.map( diff --git a/lib/modules/settings/color_scheme_picker_dialog.dart b/lib/modules/settings/color_scheme_picker_dialog.dart index 550446bc..f2933505 100644 --- a/lib/modules/settings/color_scheme_picker_dialog.dart +++ b/lib/modules/settings/color_scheme_picker_dialog.dart @@ -180,9 +180,9 @@ class ColorTile extends StatelessWidget { colorScheme.primaryContainer, colorScheme.secondary, colorScheme.secondaryContainer, - colorScheme.background, colorScheme.surface, - colorScheme.surfaceVariant, + colorScheme.surface, + colorScheme.surfaceContainerHighest, colorScheme.onPrimary, colorScheme.onSurface, ]; diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index 810c18d6..a81e3ba6 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -100,7 +100,7 @@ class LyricsPage extends HookConsumerWidget { child: Container( clipBehavior: Clip.hardEdge, decoration: BoxDecoration( - color: Theme.of(context).colorScheme.background.withOpacity(.4), + color: Theme.of(context).colorScheme.surface.withOpacity(.4), borderRadius: const BorderRadius.only( topLeft: Radius.circular(10), topRight: Radius.circular(10), diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index 0da512bb..dbff563d 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -108,8 +108,7 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.lyricsOff), style: ButtonStyle( foregroundColor: showLyrics.value - ? MaterialStateProperty.all( - theme.colorScheme.primary) + ? WidgetStateProperty.all(theme.colorScheme.primary) : null, ), onPressed: () async { @@ -133,8 +132,7 @@ class MiniLyricsPage extends HookConsumerWidget { : const Icon(SpotubeIcons.hoverOff), style: ButtonStyle( foregroundColor: hoverMode.value - ? MaterialStateProperty.all( - theme.colorScheme.primary) + ? WidgetStateProperty.all(theme.colorScheme.primary) : null, ), onPressed: () async { @@ -155,7 +153,7 @@ class MiniLyricsPage extends HookConsumerWidget { ), style: ButtonStyle( foregroundColor: snapshot.data == true - ? MaterialStateProperty.all( + ? WidgetStateProperty.all( theme.colorScheme.primary) : null, ), @@ -187,12 +185,12 @@ class MiniLyricsPage extends HookConsumerWidget { child: TabBarView( children: [ SyncedLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), + palette: PaletteColor(theme.colorScheme.surface, 0), isModal: true, defaultTextZoom: 65, ), PlainLyrics( - palette: PaletteColor(theme.colorScheme.background, 0), + palette: PaletteColor(theme.colorScheme.surface, 0), isModal: true, defaultTextZoom: 65, ), @@ -245,7 +243,7 @@ class MiniLyricsPage extends HookConsumerWidget { } : null, ), - Flexible(child: PlayerControls(compact: true)), + const Flexible(child: PlayerControls(compact: true)), IconButton( tooltip: context.l10n.exit_mini_player, icon: const Icon(SpotubeIcons.maximize), diff --git a/lib/pages/profile/profile.dart b/lib/pages/profile/profile.dart index 67bd8f57..9e51793d 100644 --- a/lib/pages/profile/profile.dart +++ b/lib/pages/profile/profile.dart @@ -120,7 +120,7 @@ class ProfilePage extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.all(6), child: Text( - '$key', + key, style: textTheme.titleSmall, ), ), diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 402a7cf0..f7aedf63 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -205,11 +205,11 @@ class RootApp extends HookConsumerWidget { ), ) : null, - bottomNavigationBar: Column( + bottomNavigationBar: const Column( mainAxisSize: MainAxisSize.min, children: [ BottomPlayer(), - const SpotubeNavigationBar(), + SpotubeNavigationBar(), ], ), ), diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index e28a5eff..d5de12f0 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -211,7 +211,7 @@ class SearchPage extends HookConsumerWidget { Icon( SpotubeIcons.web, size: 120, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.7), ), const SizedBox(height: 20), @@ -219,7 +219,7 @@ class SearchPage extends HookConsumerWidget { context.l10n.search_to_get_results, style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w900, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.5), ), ), @@ -245,7 +245,7 @@ class SearchPage extends HookConsumerWidget { style: TextStyle( fontSize: 20, fontWeight: FontWeight.w900, - color: theme.colorScheme.onBackground + color: theme.colorScheme.onSurface .withOpacity(0.7), ), ), diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index c1693079..dfb5272b 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -43,10 +43,9 @@ class SettingsAboutSection extends HookConsumerWidget { ), trailing: (context, update) => FilledButton( style: ButtonStyle( - backgroundColor: MaterialStatePropertyAll(Colors.red[100]), - foregroundColor: - const MaterialStatePropertyAll(Colors.pinkAccent), - padding: const MaterialStatePropertyAll(EdgeInsets.all(15)), + backgroundColor: WidgetStatePropertyAll(Colors.red[100]), + foregroundColor: const WidgetStatePropertyAll(Colors.pinkAccent), + padding: const WidgetStatePropertyAll(EdgeInsets.all(15)), ), onPressed: () { launchUrlString( diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index 1b5b7e39..c670e96d 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -130,7 +130,7 @@ class SettingsAccountSection extends HookConsumerWidget { : FilledButton( onPressed: onLogin, style: ButtonStyle( - shape: MaterialStateProperty.all( + shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(25.0), ), diff --git a/lib/pages/stats/fees/fees.dart b/lib/pages/stats/fees/fees.dart index 5f9aa779..da62fb30 100644 --- a/lib/pages/stats/fees/fees.dart +++ b/lib/pages/stats/fees/fees.dart @@ -70,7 +70,7 @@ class StatsStreamFeesPage extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - "Total ${usdFormatter.format(total)}", + context.l10n.total_money(usdFormatter.format(total)), style: textTheme.titleLarge, ), DropdownButton( @@ -79,30 +79,30 @@ class StatsStreamFeesPage extends HookConsumerWidget { if (value == null) return; duration.value = value; }, - items: const [ + items: [ DropdownMenuItem( value: HistoryDuration.days7, - child: Text("This week"), + child: Text(context.l10n.this_week), ), DropdownMenuItem( value: HistoryDuration.days30, - child: Text("This month"), + child: Text(context.l10n.this_month), ), DropdownMenuItem( value: HistoryDuration.months6, - child: Text("Last 6 months"), + child: Text(context.l10n.last_6_months), ), DropdownMenuItem( value: HistoryDuration.year, - child: Text("This year"), + child: Text(context.l10n.this_year), ), DropdownMenuItem( value: HistoryDuration.years2, - child: Text("Last 2 years"), + child: Text(context.l10n.last_2_years), ), DropdownMenuItem( value: HistoryDuration.allTime, - child: Text("All time"), + child: Text(context.l10n.all_time), ), ], ), diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index 3bc71942..c40f683d 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:collection/collection.dart'; import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart' hide Track; @@ -12,7 +11,6 @@ import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/discord_provider.dart'; -import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart index 3995acf7..32405910 100644 --- a/lib/services/audio_player/audio_players_streams_mixin.dart +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -45,12 +45,9 @@ mixin SpotubeAudioPlayersStreams on AudioPlayerInterface { Stream percentCompletedStream(double percent) { return positionStream .asyncMap( - (position) async => (await duration)?.inSeconds == 0 + (position) async => duration == Duration.zero ? 0 - : (position.inSeconds / - ((await duration)?.inSeconds ?? 100) * - 100) - .toInt(), + : (position.inSeconds / duration.inSeconds * 100).toInt(), ) .where((event) => event >= percent); } diff --git a/lib/themes/theme.dart b/lib/themes/theme.dart index 1129b791..485e5af7 100644 --- a/lib/themes/theme.dart +++ b/lib/themes/theme.dart @@ -4,7 +4,6 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { final scheme = ColorScheme.fromSeed( seedColor: seed, shadow: Colors.black12, - background: isAmoled ? Colors.black : null, surface: isAmoled ? Colors.black : null, brightness: brightness, ); @@ -30,7 +29,7 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { navigationBarTheme: const NavigationBarThemeData( labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, height: 50, - iconTheme: MaterialStatePropertyAll( + iconTheme: WidgetStatePropertyAll( IconThemeData(size: 18), ), ), @@ -48,7 +47,7 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), color: scheme.surface, elevation: 4, - labelTextStyle: MaterialStatePropertyAll( + labelTextStyle: WidgetStatePropertyAll( TextStyle(color: scheme.onSurface), ), ), @@ -60,25 +59,25 @@ ThemeData theme(Color seed, Brightness brightness, bool isAmoled) { ), sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay), searchBarTheme: SearchBarThemeData( - textStyle: const MaterialStatePropertyAll(TextStyle(fontSize: 15)), + textStyle: const WidgetStatePropertyAll(TextStyle(fontSize: 15)), constraints: const BoxConstraints(maxWidth: double.infinity), - padding: const MaterialStatePropertyAll(EdgeInsets.all(8)), - backgroundColor: MaterialStatePropertyAll( + padding: const WidgetStatePropertyAll(EdgeInsets.all(8)), + backgroundColor: WidgetStatePropertyAll( Color.lerp( - scheme.surfaceVariant, + scheme.surfaceContainerHighest, scheme.surface, brightness == Brightness.light ? .9 : .7, ), ), - elevation: const MaterialStatePropertyAll(0), - shape: MaterialStatePropertyAll( + elevation: const WidgetStatePropertyAll(0), + shape: WidgetStatePropertyAll( RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), ), scrollbarTheme: const ScrollbarThemeData( - thickness: MaterialStatePropertyAll(14), + thickness: WidgetStatePropertyAll(14), ), checkboxTheme: CheckboxThemeData( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), diff --git a/untranslated_messages.json b/untranslated_messages.json index 5da4c3c6..9e26dfee 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,105 +1 @@ -{ - "ar": [ - "and_n_more" - ], - - "bn": [ - "and_n_more" - ], - - "ca": [ - "and_n_more" - ], - - "cs": [ - "and_n_more" - ], - - "de": [ - "and_n_more" - ], - - "es": [ - "and_n_more" - ], - - "eu": [ - "and_n_more" - ], - - "fa": [ - "and_n_more" - ], - - "fi": [ - "and_n_more" - ], - - "fr": [ - "and_n_more" - ], - - "hi": [ - "and_n_more" - ], - - "id": [ - "and_n_more" - ], - - "it": [ - "and_n_more" - ], - - "ja": [ - "and_n_more" - ], - - "ka": [ - "and_n_more" - ], - - "ko": [ - "and_n_more" - ], - - "ne": [ - "and_n_more" - ], - - "nl": [ - "and_n_more" - ], - - "pl": [ - "and_n_more" - ], - - "pt": [ - "and_n_more" - ], - - "ru": [ - "and_n_more" - ], - - "th": [ - "and_n_more" - ], - - "tr": [ - "and_n_more" - ], - - "uk": [ - "and_n_more" - ], - - "vi": [ - "and_n_more" - ], - - "zh": [ - "and_n_more" - ] -} +{} \ No newline at end of file From b32ec9ccf92f8e2f07e1832ce56cb55a7062ecc6 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 10 Aug 2024 23:21:48 +0600 Subject: [PATCH 209/261] chore: bump version and generate changelog --- .github/workflows/spotube-publish-binary.yml | 2 +- CHANGELOG.md | 37 +++++++++++++++++++- cli/commands/build/windows.dart | 19 ++++++++++ lib/modules/root/sidebar.dart | 2 +- pubspec.lock | 4 +-- pubspec.yaml | 6 ++-- windows/runner/Runner.rc | 4 +-- 7 files changed, 64 insertions(+), 10 deletions(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 7f85173f..9d3ce470 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.7.1 + default: 3.8.0 required: true dry_run: description: Dry run diff --git a/CHANGELOG.md b/CHANGELOG.md index 22919a32..7e434574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,42 @@ 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.7.1](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.7.1) (2024-06-06) +## [3.8.0](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.8.0) (2024-06-06) + +### Features + +- translations: make state page's hard coded strings translatable (#1719) +- discord: add listening activity type +- discord: album art, playing time and play pause support (#1765) +- linux: Use XDG_STATE_HOME to storage logs (#1675) +- discord rpc for macOS, windows-arm64 and linux-arm64 (#1713) +- desktop: implement webview based login +- stats: add lazy loading support + +### Bug Fixes + +- translations: fix Russian translations (#1696) +- ios: permission exception +- linux: tray icon wrong name for flatpak +- windows: app crashes when no internet +- windows: local tracks plays but disabled playback controls +- go to track album shows up for local tracks +- local track metadata timeout +- windows: window stretching #1553 +- android: app getting killed from background +- linux: OS Media control not working for Flatpak #1627 +- incorrect datatype used for MPRIS position property #1521 +- Too many artists for a track causing overflows +- playlist share button does not work #1639 +- unescape html escape values #1300 +- lyrics page doesn't scroll to top after song ends #885 +- changed source doesn't get saved and uses the wrong once again +- null exception in album page navigated from /home +- popup menu item opacity +- linux: change app id in flatpak environment + + +## [3.7.1](https://github.com/krtirtho/spotube/compare/v3.7.0...v3.7.1) (2024-06-06) ### Bug Fixes diff --git a/cli/commands/build/windows.dart b/cli/commands/build/windows.dart index 15e0bf17..c44ed52f 100644 --- a/cli/commands/build/windows.dart +++ b/cli/commands/build/windows.dart @@ -41,6 +41,25 @@ class WindowsBuildCommand extends Command with BuildCommandCommonSteps { await bootstrap(); await innoDependInstall(); + final runnerRCFile = File( + join(cwd.path, "windows", "runner", "Runner.rc"), + ); + + runnerRCFile.writeAsStringSync( + runnerRCFile + .readAsStringSync() + .replaceAll("%{{SPOTUBE_VERSION}}%", versionWithoutBuildNumber) + .replaceAll( + "%{{SPOTUBE_VERSION_AS_NUMBER}}%", + [ + pubspec.version!.major, + pubspec.version!.minor, + pubspec.version!.patch, + 0 + ].join(","), + ), + ); + await shell.run( "flutter_distributor package --platform=windows --targets=exe --skip-clean", ); diff --git a/lib/modules/root/sidebar.dart b/lib/modules/root/sidebar.dart index 8f7f495c..f29644fb 100644 --- a/lib/modules/root/sidebar.dart +++ b/lib/modules/root/sidebar.dart @@ -75,7 +75,7 @@ class Sidebar extends HookConsumerWidget { final bg = theme.colorScheme.surfaceContainerHighest; final bgColor = useBrightnessValue( - Color.lerp(bg, Colors.white, 0.7), + Color.lerp(bg, Colors.white, 0.6), Color.lerp(bg, Colors.black, 0.45)!, ); diff --git a/pubspec.lock b/pubspec.lock index 0bc24dff..6f0f3e73 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1764,10 +1764,10 @@ packages: dependency: "direct dev" description: name: pubspec_parse - sha256: c63b2876e58e194e4b0828fcb080ad0e06d051cb607a6be51a9e084f47cb9367 + sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8 url: "https://pub.dev" source: hosted - version: "1.2.3" + version: "1.3.0" quiver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 7f6f0f29..77aa3f5b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.7.1+32 +version: 3.8.0+33 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube @@ -156,8 +156,8 @@ dev_dependencies: custom_lint: ^0.6.4 riverpod_lint: ^2.3.10 process_run: ^0.14.2 - pubspec_parse: ^1.2.2 - pub_api_client: ^2.4.0 + pubspec_parse: ^1.3.0 + pub_api_client: ^2.7.0 xml: ^6.5.0 io: ^1.0.4 drift_dev: ^2.18.0 diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 62e150f8..c77ce0c6 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -63,13 +63,13 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) #define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD #else -#define VERSION_AS_NUMBER 1,0,0,0 +#define VERSION_AS_NUMBER %{{SPOTUBE_VERSION_AS_NUMBER}}% #endif #if defined(FLUTTER_VERSION) #define VERSION_AS_STRING FLUTTER_VERSION #else -#define VERSION_AS_STRING "1.0.0" +#define VERSION_AS_STRING "%{{SPOTUBE_VERSION}}%" #endif VS_VERSION_INFO VERSIONINFO From b0a07b58d554375a7a66a76c8636dbe7064956ff Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 11 Aug 2024 13:45:03 +0600 Subject: [PATCH 210/261] cd: add playstore publish support --- .github/workflows/spotube-publish-binary.yml | 34 ++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 9d3ce470..10ad810d 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -12,10 +12,10 @@ on: type: boolean default: true jobs: - description: Jobs to run (flathub,aur,winget,chocolatey) + description: Jobs to run (flathub,aur,winget,chocolatey,playstore) required: true type: string - default: "flathub,aur,winget,chocolatey" + default: "flathub,aur,winget,chocolatey,playstore" jobs: flathub: @@ -104,3 +104,33 @@ jobs: - name: Publish to Chocolatey Repository if: ${{ !inputs.dry_run }} run: choco push Spotube-windows-x86_64.nupkg --source https://push.chocolatey.org/ + + playstore: + runs-on: ubuntu-latest + if: contains(inputs.jobs, 'playstore') + steps: + - name: Tagname (workflow dispatch) + run: echo 'TAG_NAME=${{inputs.version}}' >> $GITHUB_ENV + + - uses: robinraju/release-downloader@main + with: + tag: ${{ env.TAG_NAME }} + tarBall: false + zipBall: false + out-file-path: dist + fileName: "Spotube-playstore-all-arch.aab" + + - name: Create service-account.json + run: | + echo "${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_BASE64 }}" | base64 -d > service-account.json + + - name: Upload Android Release to Play Store + if: ${{!inputs.dry_run}} + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJson: ./service-account.json + releaseFiles: ./dist/Spotube-playstore-all-arch.aab + packageName: oss.krtirtho.spotube + track: production + status: draft + releaseName: ${{ env.TAG_NAME }} \ No newline at end of file From 1b024b41fec457a86482066c4c9f33a8b5432937 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 11 Aug 2024 13:49:00 +0600 Subject: [PATCH 211/261] cd: fix playstore publish download faiils --- .github/workflows/spotube-publish-binary.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 10ad810d..bed5085f 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -114,6 +114,7 @@ jobs: - uses: robinraju/release-downloader@main with: + repository: KRTirtho/spotube tag: ${{ env.TAG_NAME }} tarBall: false zipBall: false From c681401b6e7a32e2e49310254695f0b342bb1980 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 11 Aug 2024 13:50:39 +0600 Subject: [PATCH 212/261] cd: fix playstore publish download faiils --- .github/workflows/spotube-publish-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index bed5085f..089dd185 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -115,7 +115,7 @@ jobs: - uses: robinraju/release-downloader@main with: repository: KRTirtho/spotube - tag: ${{ env.TAG_NAME }} + tag: v${{ env.TAG_NAME }} tarBall: false zipBall: false out-file-path: dist From d0a225d0b1a966f44c1197754e4cf2f7dce51969 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 11 Aug 2024 17:16:50 +0600 Subject: [PATCH 213/261] chore: upgrade targetSdkVersion of android build.gradle --- android/app/build.gradle | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index e175f356..8ec1872e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -47,10 +47,9 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "oss.krtirtho.spotube" minSdkVersion 24 - targetSdkVersion flutter.targetSdkVersion + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true From b501078c43e59d51f5282a38cd64a4f49975d991 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 11 Aug 2024 18:07:26 +0600 Subject: [PATCH 214/261] cd: upgrade aur version --- .github/workflows/spotube-publish-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 089dd185..812849ac 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -66,7 +66,7 @@ jobs: - name: Release to AUR if: ${{ !inputs.dry_run }} - uses: KSXGitHub/github-actions-deploy-aur@v2.7.1 + uses: KSXGitHub/github-actions-deploy-aur@v2.7.2 with: pkgname: spotube-bin pkgbuild: aur-struct/PKGBUILD From a5e02d068ef2f478467f731356e8a457ea03e57f Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 11 Aug 2024 17:16:50 +0600 Subject: [PATCH 215/261] chore: upgrade targetSdkVersion of android build.gradle --- android/app/build.gradle | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index e175f356..8ec1872e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -47,10 +47,9 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "oss.krtirtho.spotube" minSdkVersion 24 - targetSdkVersion flutter.targetSdkVersion + targetSdkVersion 34 versionCode flutterVersionCode.toInteger() versionName flutterVersionName multiDexEnabled true From 9b024120601c0d381edeab4460cb22f87149d0f8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 11 Aug 2024 18:07:26 +0600 Subject: [PATCH 216/261] cd: upgrade aur version --- .github/workflows/spotube-publish-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 089dd185..812849ac 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -66,7 +66,7 @@ jobs: - name: Release to AUR if: ${{ !inputs.dry_run }} - uses: KSXGitHub/github-actions-deploy-aur@v2.7.1 + uses: KSXGitHub/github-actions-deploy-aur@v2.7.2 with: pkgname: spotube-bin pkgbuild: aur-struct/PKGBUILD From 9294858fb6ff8c66a16d2a325daccc90832764e5 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 15 Aug 2024 20:38:14 +0600 Subject: [PATCH 217/261] fix: start radio not working #1629 --- lib/components/track_tile/track_options.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index 84b0f41f..d2cb92cf 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -105,7 +105,9 @@ class TrackOptions extends HookConsumerWidget { final pages = await spotify.search.get(query, types: [SearchType.playlist]).first(); - final radios = pages.map((e) => e.items).toList().cast(); + final radios = pages + .expand((e) => e.items?.cast().toList() ?? []) + .toList(); final artists = track.artists!.map((e) => e.name); From 470addca8319b33e740d8c11889efba5b5e1ddee Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 15 Aug 2024 22:45:22 +0600 Subject: [PATCH 218/261] fix: local tracks takes time to load --- lib/hooks/configurators/use_deep_linking.dart | 51 ++++---- lib/modules/lyrics/use_synced_lyrics.dart | 9 +- lib/pages/lyrics/synced_lyrics.dart | 17 ++- lib/pages/settings/logs.dart | 12 ++ lib/provider/audio_player/audio_player.dart | 57 ++++++--- .../audio_player/audio_player_streams.dart | 65 ++++++---- lib/provider/connect/clients.dart | 76 ++++++----- lib/provider/discord_provider.dart | 42 ++++-- lib/provider/download_manager_provider.dart | 120 +++++++++--------- .../local_tracks/local_tracks_provider.dart | 57 ++++----- lib/provider/scrobbler/scrobbler.dart | 28 ++-- .../user_preferences_provider.dart | 21 ++- .../audio_services/audio_services.dart | 3 +- .../audio_services/mobile_audio_service.dart | 62 +++++---- lib/services/connectivity_adapter.dart | 27 ++-- 15 files changed, 377 insertions(+), 270 deletions(-) diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart index 90d062dc..0bb27a11 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -7,6 +7,7 @@ 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:spotube/services/logger/logger.dart'; import 'package:spotube/utils/platform.dart'; final appLinks = AppLinks(); @@ -61,30 +62,34 @@ void useDeepLinking(WidgetRef ref) { } final subscription = linkStream.listen((uri) async { - final startSegment = uri.split(":").take(2).join(":"); - final endSegment = uri.split(":").last; + try { + final startSegment = uri.split(":").take(2).join(":"); + final endSegment = uri.split(":").last; - switch (startSegment) { - case "spotify:album": - await router.push( - "/album/$endSegment", - extra: await spotify.albums.get(endSegment), - ); - break; - case "spotify:artist": - await router.push("/artist/$endSegment"); - break; - case "spotify:track": - await router.push("/track/$endSegment"); - break; - case "spotify:playlist": - await router.push( - "/playlist/$endSegment", - extra: await spotify.playlists.get(endSegment), - ); - break; - default: - break; + switch (startSegment) { + case "spotify:album": + await router.push( + "/album/$endSegment", + extra: await spotify.albums.get(endSegment), + ); + break; + case "spotify:artist": + await router.push("/artist/$endSegment"); + break; + case "spotify:track": + await router.push("/track/$endSegment"); + break; + case "spotify:playlist": + await router.push( + "/playlist/$endSegment", + extra: await spotify.playlists.get(endSegment), + ); + break; + default: + break; + } + } catch (e, stack) { + AppLogger.reportError(e, stack); } }); diff --git a/lib/modules/lyrics/use_synced_lyrics.dart b/lib/modules/lyrics/use_synced_lyrics.dart index 7a171473..cf929226 100644 --- a/lib/modules/lyrics/use_synced_lyrics.dart +++ b/lib/modules/lyrics/use_synced_lyrics.dart @@ -1,6 +1,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; int useSyncedLyrics( WidgetRef ref, @@ -13,8 +14,12 @@ int useSyncedLyrics( useEffect(() { return stream.listen((pos) { - if (lyricsMap.containsKey(pos.inSeconds + delay)) { - currentTime.value = pos.inSeconds + delay; + try { + if (lyricsMap.containsKey(pos.inSeconds + delay)) { + currentTime.value = pos.inSeconds + delay; + } + } catch (e, stack) { + AppLogger.reportError(e, stack); } }).cancel; }, [lyricsMap, delay]); diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index 643c1064..d7f7685a 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -17,6 +17,7 @@ import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:stroke_text/stroke_text.dart'; @@ -80,12 +81,16 @@ class SyncedLyrics extends HookConsumerWidget { StreamSubscription? subscription; WidgetsBinding.instance.addPostFrameCallback((_) { subscription = audioPlayer.positionStream.listen((event) { - if (event > Duration.zero) return; - controller.animateTo( - 0, - duration: const Duration(milliseconds: 500), - curve: Curves.easeInOut, - ); + try { + if (event > Duration.zero) return; + controller.animateTo( + 0, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } }); }); diff --git a/lib/pages/settings/logs.dart b/lib/pages/settings/logs.dart index 91087b7e..6ccbe32f 100644 --- a/lib/pages/settings/logs.dart +++ b/lib/pages/settings/logs.dart @@ -7,6 +7,7 @@ import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/logs/logs_provider.dart'; +import 'package:spotube/services/logger/logger.dart'; class LogsPage extends HookConsumerWidget { static const name = "logs"; @@ -40,6 +41,17 @@ class LogsPage extends HookConsumerWidget { } }, ), + IconButton( + icon: const Icon(SpotubeIcons.trash), + iconSize: 16, + onPressed: () async { + ref.invalidate(logsProvider); + + final logsFile = await AppLogger.getLogsPath(); + + await logsFile.writeAsString(""); + }, + ) ], ), body: SafeArea( diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index c40f683d..50e90dcd 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -13,6 +13,7 @@ import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/discord_provider.dart'; import 'package:spotube/provider/server/sourced_track.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; class AudioPlayerNotifier extends Notifier { BlackListNotifier get _blacklist => ref.read(blacklistProvider.notifier); @@ -141,36 +142,52 @@ class AudioPlayerNotifier extends Notifier { build() { final subscriptions = [ audioPlayer.playingStream.listen((playing) async { - state = state.copyWith(playing: playing); + try { + state = state.copyWith(playing: playing); - await _updatePlayerState( - AudioPlayerStateTableCompanion( - playing: Value(playing), - ), - ); + await _updatePlayerState( + AudioPlayerStateTableCompanion( + playing: Value(playing), + ), + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } }), audioPlayer.loopModeStream.listen((loopMode) async { - state = state.copyWith(loopMode: loopMode); + try { + state = state.copyWith(loopMode: loopMode); - await _updatePlayerState( - AudioPlayerStateTableCompanion( - loopMode: Value(loopMode), - ), - ); + await _updatePlayerState( + AudioPlayerStateTableCompanion( + loopMode: Value(loopMode), + ), + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } }), audioPlayer.shuffledStream.listen((shuffled) async { - state = state.copyWith(shuffled: shuffled); + try { + state = state.copyWith(shuffled: shuffled); - await _updatePlayerState( - AudioPlayerStateTableCompanion( - shuffled: Value(shuffled), - ), - ); + await _updatePlayerState( + AudioPlayerStateTableCompanion( + shuffled: Value(shuffled), + ), + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } }), audioPlayer.playlistStream.listen((playlist) async { - state = state.copyWith(playlist: playlist); + try { + state = state.copyWith(playlist: playlist); - await _updatePlaylist(playlist); + await _updatePlaylist(playlist); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } }), ]; diff --git a/lib/provider/audio_player/audio_player_streams.dart b/lib/provider/audio_player/audio_player_streams.dart index 845f12ea..08550844 100644 --- a/lib/provider/audio_player/audio_player_streams.dart +++ b/lib/provider/audio_player/audio_player_streams.dart @@ -73,25 +73,33 @@ class AudioPlayerStreamListeners { StreamSubscription subscribeToPlaylist() { return audioPlayer.playlistStream.listen((mpvPlaylist) { - notificationService.addTrack(audioPlayerState.activeTrack!); - discord.updatePresence(audioPlayerState.activeTrack!); - updatePalette(); + try { + notificationService.addTrack(audioPlayerState.activeTrack!); + discord.updatePresence(audioPlayerState.activeTrack!); + updatePalette(); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } }); } StreamSubscription subscribeToSkipSponsor() { return audioPlayer.positionStream.listen((position) async { - final currentSegments = await ref.read(segmentProvider.future); + try { + final currentSegments = await ref.read(segmentProvider.future); - if (currentSegments?.segments.isNotEmpty != true || - position < const Duration(seconds: 3)) return; + if (currentSegments?.segments.isNotEmpty != true || + position < const Duration(seconds: 3)) return; - for (final segment in currentSegments!.segments) { - final seconds = position.inSeconds; + for (final segment in currentSegments!.segments) { + final seconds = position.inSeconds; - if (seconds < segment.start || seconds >= segment.end) continue; + if (seconds < segment.start || seconds >= segment.end) continue; - await audioPlayer.seek(Duration(seconds: segment.end + 1)); + await audioPlayer.seek(Duration(seconds: segment.end + 1)); + } + } catch (e, stack) { + AppLogger.reportError(e, stack); } }); } @@ -122,23 +130,28 @@ class AudioPlayerStreamListeners { StreamSubscription subscribeToPosition() { String lastTrack = ""; // used to prevent multiple calls to the same track return audioPlayer.positionStream.listen((event) async { - if (event < const Duration(seconds: 3) || - audioPlayerState.playlist.index == -1 || - audioPlayerState.playlist.index == - audioPlayerState.tracks.length - 1) { - return; - } - final nextTrack = SpotubeMedia.fromMedia(audioPlayerState.playlist.medias - .elementAt(audioPlayerState.playlist.index + 1)); - - if (lastTrack == nextTrack.track.id || nextTrack.track is LocalTrack) { - return; - } - try { - await ref.read(sourcedTrackProvider(nextTrack).future); - } finally { - lastTrack = nextTrack.track.id!; + if (event < const Duration(seconds: 3) || + audioPlayerState.playlist.index == -1 || + audioPlayerState.playlist.index == + audioPlayerState.tracks.length - 1) { + return; + } + final nextTrack = SpotubeMedia.fromMedia(audioPlayerState + .playlist.medias + .elementAt(audioPlayerState.playlist.index + 1)); + + if (lastTrack == nextTrack.track.id || nextTrack.track is LocalTrack) { + return; + } + + try { + await ref.read(sourcedTrackProvider(nextTrack).future); + } finally { + lastTrack = nextTrack.track.id!; + } + } catch (e, stack) { + AppLogger.reportError(e, stack); } }); } diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart index d92ff8d3..51578a7b 100644 --- a/lib/provider/connect/clients.dart +++ b/lib/provider/connect/clients.dart @@ -1,6 +1,7 @@ import 'package:bonsoir/bonsoir.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/services/device_info/device_info.dart'; +import 'package:spotube/services/logger/logger.dart'; class ConnectClientsState { final List services; @@ -37,42 +38,47 @@ class ConnectClientsNotifier extends AsyncNotifier { final subscription = discovery.eventStream?.listen((event) { // ignore device itself - if (event.service?.attributes["deviceId"] == deviceId) { - return; - } + try { + if (event.service?.attributes["deviceId"] == deviceId) { + return; + } - switch (event.type) { - case BonsoirDiscoveryEventType.discoveryServiceFound: - state = AsyncData(state.value!.copyWith( - services: [ - ...?state.value?.services, - event.service!, - ], - )); - break; - case BonsoirDiscoveryEventType.discoveryServiceResolved: - state = AsyncData( - state.value!.copyWith( - resolvedService: event.service as ResolvedBonsoirService, - ), - ); - break; - case BonsoirDiscoveryEventType.discoveryServiceLost: - state = AsyncData( - ConnectClientsState( - services: state.value!.services - .where((s) => s.name != event.service!.name) - .toList(), - discovery: state.value!.discovery, - resolvedService: state.value?.resolvedService != null && - event.service?.name == state.value?.resolvedService?.name - ? null - : state.value!.resolvedService, - ), - ); - break; - default: - break; + switch (event.type) { + case BonsoirDiscoveryEventType.discoveryServiceFound: + state = AsyncData(state.value!.copyWith( + services: [ + ...?state.value?.services, + event.service!, + ], + )); + break; + case BonsoirDiscoveryEventType.discoveryServiceResolved: + state = AsyncData( + state.value!.copyWith( + resolvedService: event.service as ResolvedBonsoirService, + ), + ); + break; + case BonsoirDiscoveryEventType.discoveryServiceLost: + state = AsyncData( + ConnectClientsState( + services: state.value!.services + .where((s) => s.name != event.service!.name) + .toList(), + discovery: state.value!.discovery, + resolvedService: state.value?.resolvedService != null && + event.service?.name == + state.value?.resolvedService?.name + ? null + : state.value!.resolvedService, + ), + ); + break; + default: + break; + } + } catch (e, stack) { + AppLogger.reportError(e, stack); } }); diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index 8f8cb375..4db1835f 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -7,11 +7,14 @@ import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/utils/platform.dart'; class DiscordNotifier extends AsyncNotifier { @override FutureOr build() async { + if (!kIsDesktop) return; + final enabled = ref.watch( userPreferencesProvider.select((s) => s.discordPresence && kIsDesktop)); @@ -19,26 +22,38 @@ class DiscordNotifier extends AsyncNotifier { final subscriptions = [ FlutterDiscordRPC.instance.isConnectedStream.listen((connected) async { - final playback = ref.read(audioPlayerProvider); - if (connected && playback.activeTrack != null) { - await updatePresence(playback.activeTrack!); + try { + final playback = ref.read(audioPlayerProvider); + if (connected && playback.activeTrack != null) { + await updatePresence(playback.activeTrack!); + } + } catch (e, stack) { + AppLogger.reportError(e, stack); } }), audioPlayer.playerStateStream.listen((state) async { - final playback = ref.read(audioPlayerProvider); - if (playback.activeTrack == null) return; + try { + final playback = ref.read(audioPlayerProvider); + if (playback.activeTrack == null) return; - await updatePresence(ref.read(audioPlayerProvider).activeTrack!); + await updatePresence(ref.read(audioPlayerProvider).activeTrack!); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } }), audioPlayer.positionStream.listen((position) async { - final playback = ref.read(audioPlayerProvider); - if (playback.activeTrack != null) { - final diff = position.inMilliseconds - lastPosition.inMilliseconds; - if (diff > 500 || diff < -500) { - await updatePresence(ref.read(audioPlayerProvider).activeTrack!); + try { + final playback = ref.read(audioPlayerProvider); + if (playback.activeTrack != null) { + final diff = position.inMilliseconds - lastPosition.inMilliseconds; + if (diff > 500 || diff < -500) { + await updatePresence(ref.read(audioPlayerProvider).activeTrack!); + } } + lastPosition = position; + } catch (e, stack) { + AppLogger.reportError(e, stack); } - lastPosition = position; }) ]; @@ -59,6 +74,7 @@ class DiscordNotifier extends AsyncNotifier { } Future updatePresence(Track track) async { + if (!kIsDesktop) return; final artistNames = track.artists?.asString(); final isPlaying = audioPlayer.isPlaying; final position = audioPlayer.position; @@ -92,10 +108,12 @@ class DiscordNotifier extends AsyncNotifier { } Future clear() async { + if (!kIsDesktop) return; await FlutterDiscordRPC.instance.clearActivity(); } Future close() async { + if (!kIsDesktop) return; await FlutterDiscordRPC.instance.disconnect(); } } diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index ec6ffc18..8c9ffadf 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -23,68 +23,72 @@ class DownloadManagerProvider extends ChangeNotifier { $backHistory = {}, dl = DownloadManager() { dl.statusStream.listen((event) async { - final (:request, :status) = event; + try { + final (:request, :status) = event; - final track = $history.firstWhereOrNull( - (element) => element.getUrlOfCodec(downloadCodec) == request.url, - ); - if (track == null) return; + final track = $history.firstWhereOrNull( + (element) => element.getUrlOfCodec(downloadCodec) == request.url, + ); + if (track == null) return; - final savePath = getTrackFileUrl(track); - // related to onFileExists - final oldFile = File("$savePath.old"); + final savePath = getTrackFileUrl(track); + // related to onFileExists + final oldFile = File("$savePath.old"); - // if download failed and old file exists, rename it back - if ((status == DownloadStatus.failed || - status == DownloadStatus.canceled) && - await oldFile.exists()) { - await oldFile.rename(savePath); + // if download failed and old file exists, rename it back + if ((status == DownloadStatus.failed || + status == DownloadStatus.canceled) && + await oldFile.exists()) { + await oldFile.rename(savePath); + } + if (status != DownloadStatus.completed || + //? WebA audiotagging is not supported yet + //? Although in future by converting weba to opus & then tagging it + //? is possible using vorbis comments + downloadCodec == SourceCodecs.weba) return; + + final file = File(request.path); + + if (await oldFile.exists()) { + await oldFile.delete(); + } + + final imageBytes = await downloadImage( + (track.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + index: 1, + ), + ); + + final metadata = Metadata( + title: track.name, + artist: track.artists?.map((a) => a.name).join(", "), + album: track.album?.name, + albumArtist: track.artists?.map((a) => a.name).join(", "), + year: track.album?.releaseDate != null + ? int.tryParse(track.album!.releaseDate!.split("-").first) ?? 1969 + : 1969, + trackNumber: track.trackNumber, + discNumber: track.discNumber, + durationMs: track.durationMs?.toDouble() ?? 0.0, + fileSize: BigInt.from(await file.length()), + trackTotal: track.album?.tracks?.length ?? 0, + picture: imageBytes != null + ? Picture( + data: imageBytes, + // Spotify images are always JPEGs + mimeType: 'image/jpeg', + ) + : null, + ); + + await MetadataGod.writeMetadata( + file: file.path, + metadata: metadata, + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); } - if (status != DownloadStatus.completed || - //? WebA audiotagging is not supported yet - //? Although in future by converting weba to opus & then tagging it - //? is possible using vorbis comments - downloadCodec == SourceCodecs.weba) return; - - final file = File(request.path); - - if (await oldFile.exists()) { - await oldFile.delete(); - } - - final imageBytes = await downloadImage( - (track.album?.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - index: 1, - ), - ); - - final metadata = Metadata( - title: track.name, - artist: track.artists?.map((a) => a.name).join(", "), - album: track.album?.name, - albumArtist: track.artists?.map((a) => a.name).join(", "), - year: track.album?.releaseDate != null - ? int.tryParse(track.album!.releaseDate!.split("-").first) ?? 1969 - : 1969, - trackNumber: track.trackNumber, - discNumber: track.discNumber, - durationMs: track.durationMs?.toDouble() ?? 0.0, - fileSize: BigInt.from(await file.length()), - trackTotal: track.album?.tracks?.length ?? 0, - picture: imageBytes != null - ? Picture( - data: imageBytes, - // Spotify images are always JPEGs - mimeType: 'image/jpeg', - ) - : null, - ); - - await MetadataGod.writeMetadata( - file: file.path, - metadata: metadata, - ); }); } diff --git a/lib/provider/local_tracks/local_tracks_provider.dart b/lib/provider/local_tracks/local_tracks_provider.dart index ca22d841..513fd9b9 100644 --- a/lib/provider/local_tracks/local_tracks_provider.dart +++ b/lib/provider/local_tracks/local_tracks_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -72,39 +73,35 @@ final localTracksProvider = } } - final List> filesWithMetadata = []; + final List> filesWithMetadata = await Future.wait( + entities.map((file) async { + try { + final metadata = await MetadataGod.readMetadata(file: file.path); - for (final file in entities) { - try { - final metadata = await MetadataGod.readMetadata(file: file.path); + final imageFile = File(join( + (await getTemporaryDirectory()).path, + "spotube", + basenameWithoutExtension(file.path) + + imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, + )); + if (!await imageFile.exists() && metadata.picture != null) { + await imageFile.create(recursive: true); + await imageFile.writeAsBytes( + metadata.picture?.data ?? [], + mode: FileMode.writeOnly, + ); + } - await Future.delayed(const Duration(milliseconds: 50)); - - final imageFile = File(join( - (await getTemporaryDirectory()).path, - "spotube", - basenameWithoutExtension(file.path) + - imgMimeToExt[metadata.picture?.mimeType ?? "image/jpeg"]!, - )); - if (!await imageFile.exists() && metadata.picture != null) { - await imageFile.create(recursive: true); - await imageFile.writeAsBytes( - metadata.picture?.data ?? [], - mode: FileMode.writeOnly, - ); + return {"metadata": metadata, "file": file, "art": imageFile.path}; + } catch (e, stack) { + if (e case FrbException() || TimeoutException()) { + return {"file": file}; + } + AppLogger.reportError(e, stack); + return null; } - - filesWithMetadata.add( - {"metadata": metadata, "file": file, "art": imageFile.path}, - ); - } catch (e, stack) { - if (e case FrbException() || TimeoutException()) { - filesWithMetadata.add({"file": file}); - } - AppLogger.reportError(e, stack); - continue; - } - } + }), + ).then((value) => value.whereNotNull().toList()); final tracksFromMetadata = filesWithMetadata .map( diff --git a/lib/provider/scrobbler/scrobbler.dart b/lib/provider/scrobbler/scrobbler.dart index 76559d69..8aff0438 100644 --- a/lib/provider/scrobbler/scrobbler.dart +++ b/lib/provider/scrobbler/scrobbler.dart @@ -23,19 +23,23 @@ class ScrobblerNotifier extends AsyncNotifier { final subscription = database.select(database.scrobblerTable).watch().listen((event) async { - if (event.isNotEmpty) { - state = await AsyncValue.guard( - () async => Scrobblenaut( - lastFM: await LastFM.authenticateWithPasswordHash( - apiKey: Env.lastFmApiKey, - apiSecret: Env.lastFmApiSecret, - username: event.first.username, - passwordHash: event.first.passwordHash.value, + try { + if (event.isNotEmpty) { + state = await AsyncValue.guard( + () async => Scrobblenaut( + lastFM: await LastFM.authenticateWithPasswordHash( + apiKey: Env.lastFmApiKey, + apiSecret: Env.lastFmApiSecret, + username: event.first.username, + passwordHash: event.first.passwordHash.value, + ), ), - ), - ); - } else { - state = const AsyncValue.data(null); + ); + } else { + state = const AsyncValue.data(null); + } + } catch (e, stack) { + AppLogger.reportError(e, stack); } }); diff --git a/lib/provider/user_preferences/user_preferences_provider.dart b/lib/provider/user_preferences/user_preferences_provider.dart index a421e7d0..23479b71 100644 --- a/lib/provider/user_preferences/user_preferences_provider.dart +++ b/lib/provider/user_preferences/user_preferences_provider.dart @@ -10,6 +10,7 @@ import 'package:spotube/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/sourced_track/enums.dart'; import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; @@ -41,15 +42,21 @@ class UserPreferencesNotifier extends Notifier { ..where((tbl) => tbl.id.equals(0))) .watchSingle() .listen((event) async { - state = event; + try { + state = event; - if (kIsDesktop) { - await windowManager.setTitleBarStyle( - state.systemTitleBar ? TitleBarStyle.normal : TitleBarStyle.hidden, - ); + if (kIsDesktop) { + await windowManager.setTitleBarStyle( + state.systemTitleBar + ? TitleBarStyle.normal + : TitleBarStyle.hidden, + ); + } + + await audioPlayer.setAudioNormalization(state.normalizeAudio); + } catch (e, stack) { + AppLogger.reportError(e, stack); } - - await audioPlayer.setAudioNormalization(state.normalizeAudio); }); ref.onDispose(() { diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index d1820a00..5d8591ee 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -5,6 +5,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_services/mobile_audio_service.dart'; import 'package:spotube/services/audio_services/windows_audio_service.dart'; import 'package:spotube/services/sourced_track/sourced_track.dart'; @@ -30,8 +31,8 @@ class AudioServices with WidgetsBindingObserver { kIsLinux ? 'spotube' : 'com.krtirtho.Spotube', androidNotificationChannelName: 'Spotube', androidNotificationOngoing: false, - androidNotificationIcon: "drawable/ic_launcher_monochrome", androidStopForegroundOnPause: false, + androidNotificationIcon: "drawable/ic_launcher_monochrome", androidNotificationChannelDescription: "Spotube Media Controls", ), ) diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index cdd16138..84467948 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; @@ -6,6 +7,8 @@ import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:media_kit/media_kit.dart' hide Track; +import 'package:spotube/services/logger/logger.dart'; +import 'package:spotube/utils/platform.dart'; class MobileAudioService extends BaseAudioHandler { AudioSession? session; @@ -120,35 +123,40 @@ class MobileAudioService extends BaseAudioHandler { @override Future onTaskRemoved() async { await audioPlayerNotifier.stop(); - return super.onTaskRemoved(); + if (kIsAndroid) exit(0); } Future _transformEvent() async { - return PlaybackState( - controls: [ - MediaControl.skipToPrevious, - audioPlayer.isPlaying ? MediaControl.pause : MediaControl.play, - MediaControl.skipToNext, - MediaControl.stop, - ], - systemActions: { - MediaAction.seek, - }, - androidCompactActionIndices: const [0, 1, 2], - playing: audioPlayer.isPlaying, - updatePosition: audioPlayer.position, - bufferedPosition: audioPlayer.bufferedPosition, - shuffleMode: audioPlayer.isShuffled == true - ? AudioServiceShuffleMode.all - : AudioServiceShuffleMode.none, - repeatMode: switch (audioPlayer.loopMode) { - PlaylistMode.loop => AudioServiceRepeatMode.all, - PlaylistMode.single => AudioServiceRepeatMode.one, - _ => AudioServiceRepeatMode.none, - }, - processingState: audioPlayer.isBuffering - ? AudioProcessingState.loading - : AudioProcessingState.ready, - ); + try { + return PlaybackState( + controls: [ + MediaControl.skipToPrevious, + audioPlayer.isPlaying ? MediaControl.pause : MediaControl.play, + MediaControl.skipToNext, + MediaControl.stop, + ], + systemActions: { + MediaAction.seek, + }, + androidCompactActionIndices: const [0, 1, 2], + playing: audioPlayer.isPlaying, + updatePosition: audioPlayer.position, + bufferedPosition: audioPlayer.bufferedPosition, + shuffleMode: audioPlayer.isShuffled == true + ? AudioServiceShuffleMode.all + : AudioServiceShuffleMode.none, + repeatMode: switch (audioPlayer.loopMode) { + PlaylistMode.loop => AudioServiceRepeatMode.all, + PlaylistMode.single => AudioServiceRepeatMode.one, + _ => AudioServiceRepeatMode.none, + }, + processingState: audioPlayer.isBuffering + ? AudioProcessingState.loading + : AudioProcessingState.ready, + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + rethrow; + } } } diff --git a/lib/services/connectivity_adapter.dart b/lib/services/connectivity_adapter.dart index 1a3835ee..86765671 100644 --- a/lib/services/connectivity_adapter.dart +++ b/lib/services/connectivity_adapter.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/widgets.dart'; +import 'package:spotube/services/logger/logger.dart'; class ConnectionCheckerService with WidgetsBindingObserver { final _connectionStreamController = StreamController.broadcast(); @@ -16,17 +17,21 @@ class ConnectionCheckerService with WidgetsBindingObserver { Timer? timer; onConnectivityChanged.listen((connected) { - if (!connected && timer == null) { - timer = Timer.periodic(const Duration(seconds: 30), (timer) async { - if (WidgetsBinding.instance.lifecycleState == - AppLifecycleState.paused) { - return; - } - await isConnected; - }); - } else { - timer?.cancel(); - timer = null; + try { + if (!connected && timer == null) { + timer = Timer.periodic(const Duration(seconds: 30), (timer) async { + if (WidgetsBinding.instance.lifecycleState == + AppLifecycleState.paused) { + return; + } + await isConnected; + }); + } else { + timer?.cancel(); + timer = null; + } + } catch (e, stack) { + AppLogger.reportError(e, stack); } }); } From 95ff13324ee82de409ecc3fdd4d93f8fbe8d2523 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 15 Aug 2024 22:46:17 +0600 Subject: [PATCH 219/261] fix(mobile): queue doesn't persist --- lib/services/audio_services/audio_services.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/services/audio_services/audio_services.dart b/lib/services/audio_services/audio_services.dart index 5d8591ee..0b1843c4 100644 --- a/lib/services/audio_services/audio_services.dart +++ b/lib/services/audio_services/audio_services.dart @@ -74,7 +74,7 @@ class AudioServices with WidgetsBindingObserver { switch (state) { case AppLifecycleState.detached: deactivateSession(); - mobile?.stop(); + audioPlayer.pause(); break; default: break; From 6d9361f3fead9660f82a0208a884428749c4de35 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Aug 2024 10:31:32 +0600 Subject: [PATCH 220/261] docs: add webkit2gtk for arch deps --- CONTRIBUTION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index d4746a1a..dbaea34f 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -128,7 +128,7 @@ Do the following: - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Arch/Manjaro ```bash - yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan webkit2gtk-4.1 libsoup3 + yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan webkit2gtk libsoup3 ``` - Fedora ```bash From 9a0421ce38193b9a6e9f220735190e8722a73d01 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Aug 2024 11:06:51 +0600 Subject: [PATCH 221/261] fix: getting started page login page exception #1800 --- .../getting_started/sections/support.dart | 7 +- .../mobile_login/hooks/login_callback.dart | 64 +++++++++++++++++++ lib/pages/settings/sections/accounts.dart | 55 +--------------- 3 files changed, 69 insertions(+), 57 deletions(-) create mode 100644 lib/pages/mobile_login/hooks/login_callback.dart diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index b449def5..3f669557 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -6,7 +6,7 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/pages/home/home.dart'; -import 'package:spotube/pages/mobile_login/mobile_login.dart'; +import 'package:spotube/pages/mobile_login/hooks/login_callback.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:url_launcher/url_launcher_string.dart'; @@ -16,6 +16,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + final onLogin = useLoginCallback(ref); return Center( child: Column( @@ -121,9 +122,7 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { ), onPressed: () async { await KVStoreService.setDoneGettingStarted(true); - if (context.mounted) { - context.pushNamed(WebViewLogin.name); - } + await onLogin(); }, ), ], diff --git a/lib/pages/mobile_login/hooks/login_callback.dart b/lib/pages/mobile_login/hooks/login_callback.dart new file mode 100644 index 00000000..815e81d0 --- /dev/null +++ b/lib/pages/mobile_login/hooks/login_callback.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:desktop_webview_window/desktop_webview_window.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:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotube/pages/mobile_login/mobile_login.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; +import 'package:spotube/utils/platform.dart'; + +Future Function() useLoginCallback(WidgetRef ref) { + final context = useContext(); + final theme = Theme.of(context); + final authNotifier = ref.read(authenticationProvider.notifier); + + return useCallback(() async { + if (kIsMobile) { + context.pushNamed(WebViewLogin.name); + return; + } + + final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); + final applicationSupportDir = await getApplicationSupportDirectory(); + final userDataFolder = + Directory(join(applicationSupportDir.path, "webview_window_Webview2")); + + if (!await userDataFolder.exists()) { + await userDataFolder.create(); + } + + final webview = await WebviewWindow.create( + configuration: CreateConfiguration( + title: "Spotify Login", + titleBarTopPadding: kIsMacOS ? 20 : 0, + windowHeight: 720, + windowWidth: 1280, + userDataFolderWindows: userDataFolder.path, + ), + ); + webview + ..setBrightness(theme.colorScheme.brightness) + ..launch("https://accounts.spotify.com/") + ..setOnUrlRequestCallback((url) { + if (exp.hasMatch(url)) { + webview.getAllCookies().then((cookies) async { + final cookieHeader = + "sp_dc=${cookies.firstWhere((element) => element.name.contains("sp_dc")).value.replaceAll("\u0000", "")}"; + + await authNotifier.login(cookieHeader); + + webview.close(); + if (context.mounted) { + context.go("/"); + } + }); + } + + return true; + }); + }, [authNotifier, theme, context.go, context.pushNamed]); +} diff --git a/lib/pages/settings/sections/accounts.dart b/lib/pages/settings/sections/accounts.dart index c670e96d..b9a26147 100644 --- a/lib/pages/settings/sections/accounts.dart +++ b/lib/pages/settings/sections/accounts.dart @@ -1,24 +1,18 @@ -import 'dart:io'; - import 'package:auto_size_text/auto_size_text.dart'; -import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/settings/section_card_with_heading.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; -import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/pages/profile/profile.dart'; +import 'package:spotube/pages/mobile_login/hooks/login_callback.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/scrobbler/scrobbler.dart'; import 'package:spotube/provider/spotify/spotify.dart'; -import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class SettingsAccountSection extends HookConsumerWidget { @@ -30,7 +24,6 @@ class SettingsAccountSection extends HookConsumerWidget { final router = GoRouter.of(context); final auth = ref.watch(authenticationProvider); - final authNotifier = ref.watch(authenticationProvider.notifier); final scrobbler = ref.watch(scrobblerProvider); final me = ref.watch(meProvider); final meData = me.asData?.value; @@ -40,51 +33,7 @@ class SettingsAccountSection extends HookConsumerWidget { foregroundColor: Colors.white, ); - void onLogin() async { - if (kIsMobile) { - router.pushNamed(WebViewLogin.name); - return; - } - - final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); - final applicationSupportDir = await getApplicationSupportDirectory(); - final userDataFolder = Directory( - join(applicationSupportDir.path, "webview_window_Webview2")); - - if (!await userDataFolder.exists()) { - await userDataFolder.create(); - } - - final webview = await WebviewWindow.create( - configuration: CreateConfiguration( - title: "Spotify Login", - titleBarTopPadding: kIsMacOS ? 20 : 0, - windowHeight: 720, - windowWidth: 1280, - userDataFolderWindows: userDataFolder.path, - ), - ); - webview - ..setBrightness(theme.colorScheme.brightness) - ..launch("https://accounts.spotify.com/") - ..setOnUrlRequestCallback((url) { - if (exp.hasMatch(url)) { - webview.getAllCookies().then((cookies) async { - final cookieHeader = - "sp_dc=${cookies.firstWhere((element) => element.name.contains("sp_dc")).value.replaceAll("\u0000", "")}"; - - await authNotifier.login(cookieHeader); - - webview.close(); - if (context.mounted) { - context.go("/"); - } - }); - } - - return true; - }); - } + final onLogin = useLoginCallback(ref); return SectionCardWithHeading( heading: context.l10n.account, From 411115327d7f134c79f215314833d2af8eb6c22d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Aug 2024 12:00:44 +0600 Subject: [PATCH 222/261] fix(player): shuffle button state resets after closing page #1657 --- lib/modules/player/player_controls.dart | 41 ++++++++++++------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/lib/modules/player/player_controls.dart b/lib/modules/player/player_controls.dart index c88f6258..12288a3d 100644 --- a/lib/modules/player/player_controls.dart +++ b/lib/modules/player/player_controls.dart @@ -170,27 +170,26 @@ class PlayerControls extends HookConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - StreamBuilder( - stream: audioPlayer.shuffledStream, - builder: (context, snapshot) { - final shuffled = snapshot.data ?? false; - return IconButton( - tooltip: shuffled - ? context.l10n.unshuffle_playlist - : context.l10n.shuffle_playlist, - icon: const Icon(SpotubeIcons.shuffle), - style: shuffled ? activeButtonStyle : buttonStyle, - onPressed: isFetchingActiveTrack - ? null - : () { - if (shuffled) { - audioPlayer.setShuffle(false); - } else { - audioPlayer.setShuffle(true); - } - }, - ); - }), + Consumer(builder: (context, ref, _) { + final shuffled = ref + .watch(audioPlayerProvider.select((s) => s.shuffled)); + return IconButton( + tooltip: shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + icon: const Icon(SpotubeIcons.shuffle), + style: shuffled ? activeButtonStyle : buttonStyle, + onPressed: isFetchingActiveTrack + ? null + : () { + if (shuffled) { + audioPlayer.setShuffle(false); + } else { + audioPlayer.setShuffle(true); + } + }, + ); + }), IconButton( tooltip: context.l10n.previous_track, icon: const Icon(SpotubeIcons.skipBack), From af60cfc067ae688e886ac95573e0470c205ec0ba Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Aug 2024 12:42:14 +0600 Subject: [PATCH 223/261] feat: manually detect and define touch behavior #1763 --- .../configurators/use_pointer_devices.dart | 29 +++++++++++++++++++ lib/main.dart | 8 ++++- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 lib/hooks/configurators/use_pointer_devices.dart diff --git a/lib/hooks/configurators/use_pointer_devices.dart b/lib/hooks/configurators/use_pointer_devices.dart new file mode 100644 index 00000000..d7b378a5 --- /dev/null +++ b/lib/hooks/configurators/use_pointer_devices.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/utils/platform.dart'; + +Set usePointerDevices() { + final devices = useState>({ + if (kIsMobile) PointerDeviceKind.touch, + if (kIsDesktop || kIsWeb) PointerDeviceKind.mouse, + }); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + GestureBinding.instance.pointerRouter + .addGlobalRoute((PointerEvent event) { + if (devices.value.contains(event.kind)) return; + devices.value = { + ...devices.value, + event.kind, + }; + }); + }); + + return null; + }, []); + + return devices.value; +} diff --git a/lib/main.dart b/lib/main.dart index 64710f47..3f060896 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,6 +22,7 @@ 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_fix_window_stretching.dart'; import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; +import 'package:spotube/hooks/configurators/use_pointer_devices.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/database/database.dart'; @@ -92,7 +93,7 @@ Future main(List rawArgs) async { await FlutterDiscordRPC.initialize(Env.discordAppId); } - if(kIsWindows){ + if (kIsWindows) { await SMTCWindows.initialize(); } @@ -142,6 +143,7 @@ class Spotube extends HookConsumerWidget { final paletteColor = ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); final router = ref.watch(routerProvider); + final pointerDevices = usePointerDevices(); ref.listen(audioPlayerStreamListenersProvider, (_, __) {}); ref.listen(bonsoirProvider, (_, __) {}); @@ -179,6 +181,10 @@ class Spotube extends HookConsumerWidget { ); return MaterialApp.router( + scrollBehavior: const MaterialScrollBehavior() + ..copyWith( + dragDevices: pointerDevices, + ), supportedLocales: L10n.all, locale: locale.languageCode == "system" ? null : locale, localizationsDelegates: const [ From aa5d0e535bdba417692b9cba46a8db26c4e656f2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Aug 2024 13:04:45 +0600 Subject: [PATCH 224/261] chore: fix detection isn't working as drag device enables drag --- lib/hooks/configurators/use_has_touch.dart | 27 +++++++++++++++++ .../configurators/use_pointer_devices.dart | 29 ------------------- lib/main.dart | 27 ++++++++++++----- 3 files changed, 46 insertions(+), 37 deletions(-) create mode 100644 lib/hooks/configurators/use_has_touch.dart delete mode 100644 lib/hooks/configurators/use_pointer_devices.dart diff --git a/lib/hooks/configurators/use_has_touch.dart b/lib/hooks/configurators/use_has_touch.dart new file mode 100644 index 00000000..75353f27 --- /dev/null +++ b/lib/hooks/configurators/use_has_touch.dart @@ -0,0 +1,27 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/utils/platform.dart'; + +bool useHasTouch() { + final hasTouch = useState(kIsMobile); + + useEffect(() { + void globalRoute(PointerEvent event) { + if (hasTouch.value) return; + hasTouch.value = event.kind == PointerDeviceKind.touch || + event.kind == PointerDeviceKind.stylus || + event.kind == PointerDeviceKind.invertedStylus; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + GestureBinding.instance.pointerRouter.addGlobalRoute(globalRoute); + }); + + return () { + GestureBinding.instance.pointerRouter.removeGlobalRoute(globalRoute); + }; + }, []); + + return hasTouch.value; +} diff --git a/lib/hooks/configurators/use_pointer_devices.dart b/lib/hooks/configurators/use_pointer_devices.dart deleted file mode 100644 index d7b378a5..00000000 --- a/lib/hooks/configurators/use_pointer_devices.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:spotube/utils/platform.dart'; - -Set usePointerDevices() { - final devices = useState>({ - if (kIsMobile) PointerDeviceKind.touch, - if (kIsDesktop || kIsWeb) PointerDeviceKind.mouse, - }); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - GestureBinding.instance.pointerRouter - .addGlobalRoute((PointerEvent event) { - if (devices.value.contains(event.kind)) return; - devices.value = { - ...devices.value, - event.kind, - }; - }); - }); - - return null; - }, []); - - return devices.value; -} diff --git a/lib/main.dart b/lib/main.dart index 3f060896..f13991e2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:ui'; import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/foundation.dart'; @@ -22,7 +23,7 @@ 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_fix_window_stretching.dart'; import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; -import 'package:spotube/hooks/configurators/use_pointer_devices.dart'; +import 'package:spotube/hooks/configurators/use_has_touch.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/database/database.dart'; @@ -143,7 +144,7 @@ class Spotube extends HookConsumerWidget { final paletteColor = ref.watch(paletteProvider.select((s) => s?.dominantColor?.color)); final router = ref.watch(routerProvider); - final pointerDevices = usePointerDevices(); + final hasTouchSupport = useHasTouch(); ref.listen(audioPlayerStreamListenersProvider, (_, __) {}); ref.listen(bonsoirProvider, (_, __) {}); @@ -181,10 +182,6 @@ class Spotube extends HookConsumerWidget { ); return MaterialApp.router( - scrollBehavior: const MaterialScrollBehavior() - ..copyWith( - dragDevices: pointerDevices, - ), supportedLocales: L10n.all, locale: locale.languageCode == "system" ? null : locale, localizationsDelegates: const [ @@ -197,8 +194,22 @@ class Spotube extends HookConsumerWidget { debugShowCheckedModeBanner: false, title: 'Spotube', builder: (context, child) { - if (kIsDesktop && !kIsMacOS) return DragToResizeArea(child: child!); - return child!; + child = ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith( + dragDevices: hasTouchSupport + ? { + PointerDeviceKind.touch, + PointerDeviceKind.stylus, + PointerDeviceKind.invertedStylus, + } + : null, + ), + child: child!, + ); + + if (kIsDesktop && !kIsMacOS) child = DragToResizeArea(child: child); + + return child; }, themeMode: themeMode, theme: lightTheme, From d50e60e2b2062ca10782910689b8fd27e6cc3f72 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Aug 2024 15:06:49 +0600 Subject: [PATCH 225/261] cd: add action to clear space for arm build --- .github/workflows/spotube-release-binary.yml | 11 +++++++++-- CONTRIBUTION.md | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index b103ea2e..19fbac82 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -101,8 +101,15 @@ jobs: - name: Unessary hosted tools if: ${{matrix.platform == 'linux_arm'}} - run: | - sudo rm -rf /usr/share/dotnet + uses: jlumbroso/free-disk-space@main + with: + tool-cache: false + swap-storage: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true - name: Build ${{matrix.platform}} binaries run: dart cli/cli.dart build ${{matrix.platform}} diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index dbaea34f..d4746a1a 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -128,7 +128,7 @@ Do the following: - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Arch/Manjaro ```bash - yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan webkit2gtk libsoup3 + yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify avahi nss-mdns mdns-scan webkit2gtk-4.1 libsoup3 ``` - Fedora ```bash From 4385f2f472502b0cb3c009c691c1cf39d9666bae Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 18 Aug 2024 15:39:46 +0600 Subject: [PATCH 226/261] chore: remove unused deps --- pubspec.lock | 74 +--------------------------------------------------- pubspec.yaml | 3 --- 2 files changed, 1 insertion(+), 76 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 6f0f3e73..3249c759 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -458,7 +458,7 @@ packages: source: hosted version: "1.2.0" dbus: - dependency: "direct main" + dependency: transitive description: name: dbus sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" @@ -506,14 +506,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - dots_indicator: - dependency: transitive - description: - name: dots_indicator - sha256: f1599baa429936ba87f06ae5f2adc920a367b16d08f74db58c3d0f6e93bcdb5c - url: "https://pub.dev" - source: hosted - version: "2.1.2" draggable_scrollbar: dependency: "direct main" description: @@ -822,54 +814,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" - flutter_keyboard_visibility: - dependency: transitive - description: - name: flutter_keyboard_visibility - sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8" - url: "https://pub.dev" - source: hosted - version: "6.0.0" - flutter_keyboard_visibility_linux: - dependency: transitive - description: - name: flutter_keyboard_visibility_linux - sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" - url: "https://pub.dev" - source: hosted - version: "1.0.0" - flutter_keyboard_visibility_macos: - dependency: transitive - description: - name: flutter_keyboard_visibility_macos - sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 - url: "https://pub.dev" - source: hosted - version: "1.0.0" - flutter_keyboard_visibility_platform_interface: - dependency: transitive - description: - name: flutter_keyboard_visibility_platform_interface - sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 - url: "https://pub.dev" - source: hosted - version: "2.0.0" - flutter_keyboard_visibility_web: - dependency: transitive - description: - name: flutter_keyboard_visibility_web - sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1 - url: "https://pub.dev" - source: hosted - version: "2.0.0" - flutter_keyboard_visibility_windows: - dependency: transitive - description: - name: flutter_keyboard_visibility_windows - sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 - url: "https://pub.dev" - source: hosted - version: "1.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -1271,14 +1215,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" - introduction_screen: - dependency: "direct main" - description: - name: introduction_screen - sha256: "325f26e86fa3c3e86e6ab2bbc1fda860c9e6eae5ff29166fc2a3cab8f710d5b5" - url: "https://pub.dev" - source: hosted - version: "3.1.14" io: dependency: "direct dev" description: @@ -1784,14 +1720,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" - retry: - dependency: "direct main" - description: - name: retry - sha256: "822e118d5b3aafed083109c72d5f484c6dc66707885e07c0fbcb8b986bba7efc" - url: "https://pub.dev" - source: hosted - version: "3.1.2" riverpod: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 77aa3f5b..d69ab5db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,6 @@ dependencies: cached_network_image: ^3.3.1 collection: ^1.15.0 curved_navigation_bar: ^1.0.3 - dbus: ^0.7.8 desktop_webview_window: git: url: https://github.com/KRTirtho/flutter-plugins.git @@ -60,7 +59,6 @@ dependencies: html: ^0.15.1 image_picker: ^1.1.0 intl: any - introduction_screen: ^3.1.14 json_annotation: ^4.8.1 logger: ^2.0.2 media_kit: ^1.1.10+1 @@ -137,7 +135,6 @@ dependencies: sqlite3_flutter_libs: ^0.5.23 sqlite3: ^2.4.3 encrypt: ^5.0.3 - retry: ^3.1.2 dev_dependencies: build_runner: ^2.4.9 From 6d0cbf97e3af33c2860e1a069a31a9bc9a747a83 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 19 Aug 2024 21:37:31 +0600 Subject: [PATCH 227/261] fix(android): clears queue upon swiping away notification --- ios/Podfile.lock | 6 ------ lib/services/audio_services/mobile_audio_service.dart | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 7e5f24b5..a59f65eb 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -58,8 +58,6 @@ PODS: - flutter_inappwebview_ios/Core (0.0.1): - Flutter - OrderedSet (~> 5.0) - - flutter_keyboard_visibility (0.0.1): - - Flutter - flutter_native_splash (0.0.1): - Flutter - flutter_secure_storage (6.0.0): @@ -124,7 +122,6 @@ DEPENDENCIES: - flutter_broadcasts (from `.symlinks/plugins/flutter_broadcasts/ios`) - flutter_discord_rpc (from `.symlinks/plugins/flutter_discord_rpc/ios`) - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) - - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_sharing_intent (from `.symlinks/plugins/flutter_sharing_intent/ios`) @@ -173,8 +170,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_discord_rpc/ios" flutter_inappwebview_ios: :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" - flutter_keyboard_visibility: - :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_secure_storage: @@ -220,7 +215,6 @@ SPEC CHECKSUMS: flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 flutter_discord_rpc: e1c342f29ceb9dd76cdc01db59a70c93bb4d9ec5 flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 - flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 diff --git a/lib/services/audio_services/mobile_audio_service.dart b/lib/services/audio_services/mobile_audio_service.dart index 84467948..56fe0fc4 100644 --- a/lib/services/audio_services/mobile_audio_service.dart +++ b/lib/services/audio_services/mobile_audio_service.dart @@ -122,7 +122,7 @@ class MobileAudioService extends BaseAudioHandler { @override Future onTaskRemoved() async { - await audioPlayerNotifier.stop(); + await audioPlayer.pause(); if (kIsAndroid) exit(0); } From 2d4c9cabd2d3204ca009ed8c45b54d8aff53d32e Mon Sep 17 00:00:00 2001 From: sonu36437 <90464285+sonu36437@users.noreply.github.com> Date: Thu, 22 Aug 2024 21:32:33 +0530 Subject: [PATCH 228/261] s in safari was missing in webview for userAgent --- lib/pages/mobile_login/mobile_login.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 290c2b2f..10a989cf 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -27,7 +27,7 @@ class WebViewLogin extends HookConsumerWidget { child: InAppWebView( initialSettings: InAppWebViewSettings( userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 afari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 safari/537.36", ), initialUrlRequest: URLRequest( url: WebUri("https://accounts.spotify.com/"), From efada35ce0a091e41d749f2cc0e73129fcd0c015 Mon Sep 17 00:00:00 2001 From: Vedant <83997633+vedantmgoyal9@users.noreply.github.com> Date: Sat, 31 Aug 2024 20:30:04 +0530 Subject: [PATCH 229/261] Update winget-releaser to latest --- .github/workflows/spotube-publish-binary.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 812849ac..47ea1599 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -76,12 +76,12 @@ jobs: commit_message: Updated to v${{ inputs.version }} winget: - runs-on: windows-latest + runs-on: ubuntu-latest if: contains(inputs.jobs, 'winget') steps: - name: Release winget package if: ${{ !inputs.dry_run }} - uses: vedantmgoyal2009/winget-releaser@v2 + uses: vedantmgoyal9/winget-releaser@main with: version: ${{ inputs.version }} release-tag: v${{ inputs.version }} @@ -134,4 +134,4 @@ jobs: packageName: oss.krtirtho.spotube track: production status: draft - releaseName: ${{ env.TAG_NAME }} \ No newline at end of file + releaseName: ${{ env.TAG_NAME }} From 40bfcc1961b78c8f6ea265e8a512fd6a6c99c909 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 13 Sep 2024 20:12:44 +0600 Subject: [PATCH 230/261] chore: clear or disconnect discord on dispose --- lib/provider/discord_provider.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index 4db1835f..9b98e24f 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -61,6 +61,7 @@ class DiscordNotifier extends AsyncNotifier { for (final subscription in subscriptions) { subscription.cancel(); } + await clear(); await close(); await FlutterDiscordRPC.instance.dispose(); }); @@ -68,7 +69,7 @@ class DiscordNotifier extends AsyncNotifier { if (!enabled && FlutterDiscordRPC.instance.isConnected) { await clear(); await close(); - } else { + } else if (enabled) { await FlutterDiscordRPC.instance.connect(autoRetry: true); } } From 3afe3cea80eb8a662190035519cbff804a81e54e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 14 Sep 2024 10:48:39 +0600 Subject: [PATCH 231/261] Squashed commit of the following: commit e160d4f561ff2e945fd67bf12b223c012da58b1e Author: Kingkor Roy Tirtho Date: Sat Sep 14 10:48:08 2024 +0600 fix: pagination issues in playlist and album pages --- .../sections/body/track_view_body.dart | 103 +++++++++--------- lib/provider/history/top/albums.dart | 14 ++- lib/provider/history/top/playlists.dart | 14 ++- lib/provider/history/top/tracks.dart | 14 ++- lib/provider/spotify/album/tracks.dart | 16 ++- lib/provider/spotify/artist/albums.dart | 16 ++- lib/provider/spotify/category/playlists.dart | 16 ++- lib/provider/spotify/playlist/tracks.dart | 14 ++- lib/provider/spotify/search/search.dart | 24 +++- .../utils/provider/paginated_family.dart | 47 ++++---- pubspec.lock | 8 ++ pubspec.yaml | 1 + 12 files changed, 178 insertions(+), 109 deletions(-) diff --git a/lib/components/tracks_view/sections/body/track_view_body.dart b/lib/components/tracks_view/sections/body/track_view_body.dart index df841b8d..faba247a 100644 --- a/lib/components/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/tracks_view/sections/body/track_view_body.dart @@ -65,6 +65,56 @@ class TrackViewBodySection extends HookConsumerWidget { final isActive = playlist.collections.contains(props.collectionId); + final onTapTrackTile = useCallback((Track track, int index) async { + if (trackViewState.isSelecting) { + trackViewState.toggleTrackSelection(track.id!); + return; + } + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remoteQueue = ref.read(queueProvider); + if (remoteQueue.collections.contains(props.collectionId) || + remoteQueue.tracks.any((s) => s.id == track.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await remotePlayback.load( + props.collection is AlbumSimple + ? WebSocketLoadEventData.album( + tracks: tracks, + collection: props.collection as AlbumSimple, + initialIndex: index, + ) + : WebSocketLoadEventData.playlist( + tracks: tracks, + collection: props.collection as PlaylistSimple, + initialIndex: index, + ), + ); + } + } else { + if (isActive || playlist.tracks.contains(track)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await playlistNotifier.load( + tracks, + initialIndex: index, + autoPlay: true, + ); + playlistNotifier.addCollection(props.collectionId); + if (props.collection is AlbumSimple) { + historyNotifier.addAlbums([props.collection as AlbumSimple]); + } else { + historyNotifier.addPlaylists([props.collection as PlaylistSimple]); + } + } + } + }, [isActive, playlist, props, playlistNotifier, historyNotifier]); + return SliverMainAxisGroup( slivers: [ SliverToBoxAdapter( @@ -130,58 +180,7 @@ class TrackViewBodySection extends HookConsumerWidget { trackViewState.selectTrack(track.id!); HapticFeedback.selectionClick(); }, - onTap: () async { - if (trackViewState.isSelecting) { - trackViewState.toggleTrackSelection(track.id!); - return; - } - - final isRemoteDevice = - await showSelectDeviceDialog(context, ref); - - if (isRemoteDevice) { - final remotePlayback = ref.read(connectProvider.notifier); - final remoteQueue = ref.read(queueProvider); - if (remoteQueue.collections.contains(props.collectionId) || - remoteQueue.tracks.any((s) => s.id == track.id)) { - await playlistNotifier.jumpToTrack(track); - } else { - final tracks = await props.pagination.onFetchAll(); - await remotePlayback.load( - props.collection is AlbumSimple - ? WebSocketLoadEventData.album( - tracks: tracks, - collection: props.collection as AlbumSimple, - initialIndex: index, - ) - : WebSocketLoadEventData.playlist( - tracks: tracks, - collection: props.collection as PlaylistSimple, - initialIndex: index, - ), - ); - } - } else { - if (isActive || playlist.tracks.contains(track)) { - await playlistNotifier.jumpToTrack(track); - } else { - final tracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - tracks, - initialIndex: index, - autoPlay: true, - ); - playlistNotifier.addCollection(props.collectionId); - if (props.collection is AlbumSimple) { - historyNotifier - .addAlbums([props.collection as AlbumSimple]); - } else { - historyNotifier - .addPlaylists([props.collection as PlaylistSimple]); - } - } - } - }, + onTap: () => onTapTrackTile(track, index), ); }, ), diff --git a/lib/provider/history/top/albums.dart b/lib/provider/history/top/albums.dart index 7448a849..b11e62d2 100644 --- a/lib/provider/history/top/albums.dart +++ b/lib/provider/history/top/albums.dart @@ -90,12 +90,18 @@ class HistoryTopAlbumsNotifier extends FamilyPaginatedAsyncNotifier< fetch(arg, offset, limit) async { final albumsQuery = createAlbumsQuery(limit: limit, offset: offset); - return getAlbumsWithCount(await albumsQuery.get()); + final items = getAlbumsWithCount(await albumsQuery.get()); + + return ( + items: items, + hasMore: items.length == limit, + nextOffset: offset + limit, + ); } @override build(arg) async { - final albums = await fetch(arg, 0, 20); + final (items: albums, :hasMore, :nextOffset) = await fetch(arg, 0, 20); final subscription = createAlbumsQuery().watch().listen((event) { if (state.asData == null) return; @@ -111,9 +117,9 @@ class HistoryTopAlbumsNotifier extends FamilyPaginatedAsyncNotifier< return HistoryTopAlbumsState( items: albums, - offset: albums.length, + offset: nextOffset, limit: 20, - hasMore: true, + hasMore: hasMore, ); } diff --git a/lib/provider/history/top/playlists.dart b/lib/provider/history/top/playlists.dart index 04071f7a..19eb3622 100644 --- a/lib/provider/history/top/playlists.dart +++ b/lib/provider/history/top/playlists.dart @@ -55,12 +55,18 @@ class HistoryTopPlaylistsNotifier extends FamilyPaginatedAsyncNotifier< fetch(arg, offset, limit) async { final playlistsQuery = createPlaylistsQuery()..limit(limit, offset: offset); - return getPlaylistsWithCount(await playlistsQuery.get()); + final items = getPlaylistsWithCount(await playlistsQuery.get()); + + return ( + items: items, + hasMore: items.length == limit, + nextOffset: offset + limit, + ); } @override build(arg) async { - final playlists = await fetch(arg, 0, 20); + final (items: playlists, :hasMore, :nextOffset) = await fetch(arg, 0, 20); final subscription = createPlaylistsQuery().watch().listen((event) { if (state.asData == null) return; @@ -76,9 +82,9 @@ class HistoryTopPlaylistsNotifier extends FamilyPaginatedAsyncNotifier< return HistoryTopPlaylistsState( items: playlists, - offset: playlists.length, + offset: nextOffset, limit: 20, - hasMore: true, + hasMore: hasMore, ); } diff --git a/lib/provider/history/top/tracks.dart b/lib/provider/history/top/tracks.dart index 56795cc6..b737d148 100644 --- a/lib/provider/history/top/tracks.dart +++ b/lib/provider/history/top/tracks.dart @@ -89,12 +89,18 @@ class HistoryTopTracksNotifier extends FamilyPaginatedAsyncNotifier< fetch(arg, offset, limit) async { final tracksQuery = createTracksQuery()..limit(limit, offset: offset); - return getTracksWithCount(await tracksQuery.get()); + final items = getTracksWithCount(await tracksQuery.get()); + + return ( + items: items, + hasMore: items.length == limit, + nextOffset: offset + limit, + ); } @override build(arg) async { - final tracks = await fetch(arg, 0, 20); + final (items: tracks, :hasMore, :nextOffset) = await fetch(arg, 0, 20); final subscription = createTracksQuery().watch().listen((event) { if (state.asData == null) return; @@ -110,9 +116,9 @@ class HistoryTopTracksNotifier extends FamilyPaginatedAsyncNotifier< return HistoryTopTracksState( items: tracks, - offset: tracks.length, + offset: nextOffset, limit: 20, - hasMore: true, + hasMore: hasMore, ); } diff --git a/lib/provider/spotify/album/tracks.dart b/lib/provider/spotify/album/tracks.dart index e9f712e7..e39abad5 100644 --- a/lib/provider/spotify/album/tracks.dart +++ b/lib/provider/spotify/album/tracks.dart @@ -31,7 +31,13 @@ class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier e.asTrack(arg)).toList() ?? []; + final items = tracks.items?.map((e) => e.asTrack(arg)).toList() ?? []; + + return ( + items: items, + hasMore: !tracks.isLast, + nextOffset: tracks.nextOffset, + ); } @override @@ -39,12 +45,12 @@ class AlbumTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier s.market), ); - final albums = await fetch(arg, 0, 20); + final (:items, :hasMore, :nextOffset) = await fetch(arg, 0, 20); return ArtistAlbumsState( - items: albums, - offset: 0, + items: items, + offset: nextOffset, limit: 20, - hasMore: albums.length == 20, + hasMore: hasMore, ); } } diff --git a/lib/provider/spotify/category/playlists.dart b/lib/provider/spotify/category/playlists.dart index 18d4845f..9f1034be 100644 --- a/lib/provider/spotify/category/playlists.dart +++ b/lib/provider/spotify/category/playlists.dart @@ -39,7 +39,13 @@ class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< (json) => PlaylistsFeatured.fromJson(json), ).getPage(limit, offset); - return playlists.items?.whereNotNull().toList() ?? []; + final items = playlists.items?.whereNotNull().toList() ?? []; + + return ( + items: items, + hasMore: !playlists.isLast, + nextOffset: playlists.nextOffset, + ); } @override @@ -50,13 +56,13 @@ class CategoryPlaylistsNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< ref.watch(userPreferencesProvider.select((s) => s.locale)); ref.watch(userPreferencesProvider.select((s) => s.market)); - final playlists = await fetch(arg, 0, 8); + final (:items, :hasMore, :nextOffset) = await fetch(arg, 0, 8); return CategoryPlaylistsState( - items: playlists, - offset: 0, + items: items, + offset: nextOffset, limit: 8, - hasMore: playlists.length == 8, + hasMore: hasMore, ); } } diff --git a/lib/provider/spotify/playlist/tracks.dart b/lib/provider/spotify/playlist/tracks.dart index 1803f6fc..379ad110 100644 --- a/lib/provider/spotify/playlist/tracks.dart +++ b/lib/provider/spotify/playlist/tracks.dart @@ -36,10 +36,16 @@ class PlaylistTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< /// Filter out tracks with null id because some personal playlists /// may contain local tracks that are not available in the Spotify catalog - return tracks.items + final items = tracks.items ?.where((track) => track.id != null && track.type == "track") .toList() ?? []; + + return ( + items: items, + hasMore: !tracks.isLast, + nextOffset: tracks.nextOffset, + ); } @override @@ -47,13 +53,13 @@ class PlaylistTracksNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier< ref.cacheFor(); ref.watch(spotifyProvider); - final tracks = await fetch(arg, 0, 20); + final (items: tracks, :hasMore, :nextOffset) = await fetch(arg, 0, 20); return PlaylistTracksState( items: tracks, - offset: 0, + offset: nextOffset, limit: 20, - hasMore: tracks.length == 20, + hasMore: hasMore, ); } } diff --git a/lib/provider/spotify/search/search.dart b/lib/provider/spotify/search/search.dart index dc00d913..5bbc02e4 100644 --- a/lib/provider/spotify/search/search.dart +++ b/lib/provider/spotify/search/search.dart @@ -37,7 +37,13 @@ class SearchNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier[], + hasMore: false, + nextOffset: 0, + ); + } final results = await spotify.search .get( ref.read(searchTermStateProvider), @@ -46,7 +52,13 @@ class SearchNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier e.items ?? []).toList().cast(); + final items = results.expand((e) => e.items ?? []).toList().cast(); + + return ( + items: items, + hasMore: items.length == limit, + nextOffset: offset + limit, + ); } @override @@ -59,13 +71,13 @@ class SearchNotifier extends AutoDisposeFamilyPaginatedAsyncNotifier value.market), ); - final results = await fetch(arg, 0, 10); + final (:items, :hasMore, :nextOffset) = await fetch(arg, 0, 10); return SearchState( - items: results, - offset: 0, + items: items, + offset: nextOffset, limit: 10, - hasMore: results.length == 10, + hasMore: hasMore, ); } } diff --git a/lib/provider/spotify/utils/provider/paginated_family.dart b/lib/provider/spotify/utils/provider/paginated_family.dart index 84c6ba20..c08c8673 100644 --- a/lib/provider/spotify/utils/provider/paginated_family.dart +++ b/lib/provider/spotify/utils/provider/paginated_family.dart @@ -1,10 +1,16 @@ part of '../../spotify.dart'; +typedef PseudoPaginatedProps = ({ + List items, + int nextOffset, + bool hasMore, +}); + abstract class FamilyPaginatedAsyncNotifier< K, T extends BasePaginatedState, A> extends FamilyAsyncNotifier with SpotifyMixin { - Future> fetch(A arg, int offset, int limit); + Future> fetch(A arg, int offset, int limit); Future fetchMore() async { if (state.value == null || !state.value!.hasMore) return; @@ -13,18 +19,18 @@ abstract class FamilyPaginatedAsyncNotifier< state = await AsyncValue.guard( () async { - final items = await fetch( + final (:items, :hasMore, :nextOffset) = await fetch( arg, - state.value!.offset + state.value!.limit, + state.value!.offset, state.value!.limit, ); return state.value!.copyWith( - hasMore: items.length == state.value!.limit, + hasMore: hasMore, items: [ ...state.value!.items, ...items, ], - offset: state.value!.offset + state.value!.limit, + offset: nextOffset, ) as T; }, ); @@ -37,16 +43,16 @@ abstract class FamilyPaginatedAsyncNotifier< bool hasMore = true; while (hasMore) { await update((state) async { - final items = await fetch( + final res = await fetch( arg, - state.offset + state.limit, + state.offset, state.limit, ); - hasMore = items.length == state.limit; + hasMore = res.hasMore; return state.copyWith( - items: [...state.items, ...items], - offset: state.offset + state.limit, + items: [...state.items, ...res.items], + offset: res.nextOffset, hasMore: hasMore, ) as T; }); @@ -60,7 +66,7 @@ abstract class AutoDisposeFamilyPaginatedAsyncNotifier< K, T extends BasePaginatedState, A> extends AutoDisposeFamilyAsyncNotifier with SpotifyMixin { - Future> fetch(A arg, int offset, int limit); + Future> fetch(A arg, int offset, int limit); Future fetchMore() async { if (state.value == null || !state.value!.hasMore) return; @@ -69,18 +75,19 @@ abstract class AutoDisposeFamilyPaginatedAsyncNotifier< state = await AsyncValue.guard( () async { - final items = await fetch( + final (:items, :hasMore, :nextOffset) = await fetch( arg, - state.value!.offset + state.value!.limit, + state.value!.offset, state.value!.limit, ); + return state.value!.copyWith( - hasMore: items.length == state.value!.limit, + hasMore: hasMore, items: [ ...state.value!.items, ...items, ], - offset: state.value!.offset + state.value!.limit, + offset: nextOffset, ) as T; }, ); @@ -93,16 +100,16 @@ abstract class AutoDisposeFamilyPaginatedAsyncNotifier< bool hasMore = true; while (hasMore) { await update((state) async { - final items = await fetch( + final res = await fetch( arg, - state.offset + state.limit, + state.offset, state.limit, ); - hasMore = items.length == state.limit; + hasMore = res.hasMore; return state.copyWith( - items: [...state.items, ...items], - offset: state.offset + state.limit, + items: [...state.items, ...res.items], + offset: res.nextOffset, hasMore: hasMore, ) as T; }); diff --git a/pubspec.lock b/pubspec.lock index 3249c759..a1494a2d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -758,6 +758,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.5" + flutter_hooks_lint: + dependency: "direct dev" + description: + name: flutter_hooks_lint + sha256: fc6e18505b597737e5d620656e340ac60e7a58980cca29e18c1216bd15083674 + url: "https://pub.dev" + source: hosted + version: "1.2.0" flutter_inappwebview: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d69ab5db..402cd474 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -158,6 +158,7 @@ dev_dependencies: xml: ^6.5.0 io: ^1.0.4 drift_dev: ^2.18.0 + flutter_hooks_lint: ^1.2.0 dependency_overrides: uuid: ^4.4.0 From 29015bca7616b5e15849dea275705b3c98fc1e38 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 09:56:59 +0600 Subject: [PATCH 232/261] fix(stats): minutes page shows plays and streams page shows minutes which should be the opposite #1880 --- lib/pages/stats/minutes/minutes.dart | 4 ++-- lib/pages/stats/streams/streams.dart | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/pages/stats/minutes/minutes.dart b/lib/pages/stats/minutes/minutes.dart index 35bea3ab..3ad0984b 100644 --- a/lib/pages/stats/minutes/minutes.dart +++ b/lib/pages/stats/minutes/minutes.dart @@ -49,8 +49,8 @@ class StatsMinutesPage extends HookConsumerWidget { return StatsTrackItem( track: track.track, info: Text( - context.l10n - .count_plays(compactNumberFormatter.format(track.count)), + context.l10n.count_mins(compactNumberFormatter + .format(track.count * track.track.duration!.inMinutes)), ), ); }, diff --git a/lib/pages/stats/streams/streams.dart b/lib/pages/stats/streams/streams.dart index 5c90e879..059366e0 100644 --- a/lib/pages/stats/streams/streams.dart +++ b/lib/pages/stats/streams/streams.dart @@ -49,8 +49,8 @@ class StatsStreamsPage extends HookConsumerWidget { return StatsTrackItem( track: track.track, info: Text( - context.l10n.count_mins(compactNumberFormatter - .format(track.count * track.track.duration!.inMinutes)), + context.l10n + .count_plays(compactNumberFormatter.format(track.count)), ), ); }, From 33ecbe066c1f6f425935a841f3c31ac1b2bb1c16 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 10:31:20 +0600 Subject: [PATCH 233/261] feat(desktop): show error dialog if webview is not found on login #1871 --- lib/l10n/app_en.arb | 5 +- .../mobile_login/hooks/login_callback.dart | 87 ++++++---- .../no_webview_runtime_dialog.dart | 50 ++++++ untranslated_messages.json | 158 +++++++++++++++++- 4 files changed, 262 insertions(+), 38 deletions(-) create mode 100644 lib/pages/mobile_login/no_webview_runtime_dialog.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 06a90d79..c63f8543 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "Got your love", "summary_playlists": "playlists", "summary_were_on_repeat": "Were on repeat", - "total_money": "Total {money}" + "total_money": "Total {money}", + "webview_not_found": "Webview not found", + "webview_not_found_description": "No webview runtime is installed in your device.\nIf it's installed make sure it's in the Environment PATH\n\nAfter installing, restart the app", + "unsupported_platform": "Unsupported platform" } \ No newline at end of file diff --git a/lib/pages/mobile_login/hooks/login_callback.dart b/lib/pages/mobile_login/hooks/login_callback.dart index 815e81d0..1648da19 100644 --- a/lib/pages/mobile_login/hooks/login_callback.dart +++ b/lib/pages/mobile_login/hooks/login_callback.dart @@ -2,12 +2,14 @@ import 'dart:io'; import 'package:desktop_webview_window/desktop_webview_window.dart'; 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:path/path.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart'; +import 'package:spotube/pages/mobile_login/no_webview_runtime_dialog.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/platform.dart'; @@ -22,43 +24,56 @@ Future Function() useLoginCallback(WidgetRef ref) { return; } - final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); - final applicationSupportDir = await getApplicationSupportDirectory(); - final userDataFolder = - Directory(join(applicationSupportDir.path, "webview_window_Webview2")); + try { + final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); + final applicationSupportDir = await getApplicationSupportDirectory(); + final userDataFolder = Directory( + join(applicationSupportDir.path, "webview_window_Webview2")); - if (!await userDataFolder.exists()) { - await userDataFolder.create(); + if (!await userDataFolder.exists()) { + await userDataFolder.create(); + } + + final webview = await WebviewWindow.create( + configuration: CreateConfiguration( + title: "Spotify Login", + titleBarTopPadding: kIsMacOS ? 20 : 0, + windowHeight: 720, + windowWidth: 1280, + userDataFolderWindows: userDataFolder.path, + ), + ); + webview + ..setBrightness(theme.colorScheme.brightness) + ..launch("https://accounts.spotify.com/") + ..setOnUrlRequestCallback((url) { + if (exp.hasMatch(url)) { + webview.getAllCookies().then((cookies) async { + final cookieHeader = + "sp_dc=${cookies.firstWhere((element) => element.name.contains("sp_dc")).value.replaceAll("\u0000", "")}"; + + await authNotifier.login(cookieHeader); + + webview.close(); + if (context.mounted) { + context.go("/"); + } + }); + } + + return true; + }); + } on PlatformException catch (_) { + if (!await WebviewWindow.isWebviewAvailable()) { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + showDialog( + context: context, + builder: (context) { + return const NoWebviewRuntimeDialog(); + }, + ); + }); + } } - - final webview = await WebviewWindow.create( - configuration: CreateConfiguration( - title: "Spotify Login", - titleBarTopPadding: kIsMacOS ? 20 : 0, - windowHeight: 720, - windowWidth: 1280, - userDataFolderWindows: userDataFolder.path, - ), - ); - webview - ..setBrightness(theme.colorScheme.brightness) - ..launch("https://accounts.spotify.com/") - ..setOnUrlRequestCallback((url) { - if (exp.hasMatch(url)) { - webview.getAllCookies().then((cookies) async { - final cookieHeader = - "sp_dc=${cookies.firstWhere((element) => element.name.contains("sp_dc")).value.replaceAll("\u0000", "")}"; - - await authNotifier.login(cookieHeader); - - webview.close(); - if (context.mounted) { - context.go("/"); - } - }); - } - - return true; - }); }, [authNotifier, theme, context.go, context.pushNamed]); } diff --git a/lib/pages/mobile_login/no_webview_runtime_dialog.dart b/lib/pages/mobile_login/no_webview_runtime_dialog.dart new file mode 100644 index 00000000..a6cc5ffb --- /dev/null +++ b/lib/pages/mobile_login/no_webview_runtime_dialog.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class NoWebviewRuntimeDialog extends StatelessWidget { + const NoWebviewRuntimeDialog({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData(:platform) = Theme.of(context); + + return AlertDialog( + title: Text(context.l10n.webview_not_found), + content: Text(context.l10n.webview_not_found_description), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(context.l10n.cancel), + ), + FilledButton( + onPressed: () async { + final url = switch (platform) { + TargetPlatform.windows => + 'https://developer.microsoft.com/en-us/microsoft-edge/webview2', + TargetPlatform.macOS => 'https://www.apple.com/safari/', + TargetPlatform.linux => + 'https://webkitgtk.org/reference/webkit2gtk/stable/', + _ => "", + }; + if (url.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Unsupported platform')), + ); + } + + await launchUrlString(url); + }, + child: Text(switch (platform) { + TargetPlatform.windows => 'Download Edge WebView2', + TargetPlatform.macOS => 'Download Safari', + TargetPlatform.linux => 'Download Webkit2Gtk', + _ => 'Download Webview', + }), + ), + ], + ); + } +} diff --git a/untranslated_messages.json b/untranslated_messages.json index 9e26dfee..01124edd 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1 +1,157 @@ -{} \ No newline at end of file +{ + "ar": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "bn": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "ca": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "cs": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "de": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "es": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "eu": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "fa": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "fi": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "fr": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "hi": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "id": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "it": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "ja": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "ka": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "ko": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "ne": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "nl": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "pl": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "pt": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "ru": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "th": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "tr": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "uk": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "vi": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ], + + "zh": [ + "webview_not_found", + "webview_not_found_description", + "unsupported_platform" + ] +} From 959199ff84d61159b2886525fa81bd9699bfffcf Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 10:36:27 +0600 Subject: [PATCH 234/261] fix(discord): stop discord rpc from try update presence when not connected --- lib/provider/discord_provider.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/provider/discord_provider.dart b/lib/provider/discord_provider.dart index 9b98e24f..8f81fc51 100644 --- a/lib/provider/discord_provider.dart +++ b/lib/provider/discord_provider.dart @@ -76,6 +76,7 @@ class DiscordNotifier extends AsyncNotifier { Future updatePresence(Track track) async { if (!kIsDesktop) return; + if (FlutterDiscordRPC.instance.isConnected == false) return; final artistNames = track.artists?.asString(); final isPlaying = audioPlayer.isPlaying; final position = audioPlayer.position; From 36d161c05a720d02f7075f310b7545fe0569965d Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 10:51:13 +0600 Subject: [PATCH 235/261] fix(desktop): scrollbar overlapping with more options of tracks and playlists --- lib/components/track_tile/track_tile.dart | 3 +++ .../tracks_view/sections/body/track_view_body_headers.dart | 3 +++ 2 files changed, 6 insertions(+) diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index 12ce063f..8ab889f8 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/gestures.dart'; 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'; @@ -21,6 +22,7 @@ import 'package:spotube/pages/track/track.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/service_utils.dart'; class TrackTile extends HookConsumerWidget { @@ -276,6 +278,7 @@ class TrackTile extends HookConsumerWidget { userPlaylist: userPlaylist, showMenuCbRef: showOptionCbRef, ), + if (kIsDesktop) const Gap(10), ], ), ), diff --git a/lib/components/tracks_view/sections/body/track_view_body_headers.dart b/lib/components/tracks_view/sections/body/track_view_body_headers.dart index 564c85d0..82cc7706 100644 --- a/lib/components/tracks_view/sections/body/track_view_body_headers.dart +++ b/lib/components/tracks_view/sections/body/track_view_body_headers.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/expandable_search/expandable_search.dart'; import 'package:spotube/components/sort_tracks_dropdown.dart'; @@ -7,6 +8,7 @@ import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/components/tracks_view/track_view_provider.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/utils/platform.dart'; class TrackViewBodyHeaders extends HookConsumerWidget { final ValueNotifier isFiltering; @@ -94,6 +96,7 @@ class TrackViewBodyHeaders extends HookConsumerWidget { }, ), const TrackViewBodyOptions(), + if (kIsDesktop) const Gap(10), ], ); }, From 9cb828bb55e42b2084396395c541d6c3e70199dc Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 13:59:09 +0600 Subject: [PATCH 236/261] fix: handle dublicated items in playback queue correctly #1852 --- .../sections/body/track_view_body.dart | 3 ++- lib/extensions/list.dart | 19 ++++++++++++++++++ lib/pages/track/track.dart | 7 +++++-- lib/provider/audio_player/audio_player.dart | 20 +++++++++++++++++-- pubspec.lock | 8 -------- pubspec.yaml | 1 - 6 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 lib/extensions/list.dart diff --git a/lib/components/tracks_view/sections/body/track_view_body.dart b/lib/components/tracks_view/sections/body/track_view_body.dart index faba247a..0f161b0c 100644 --- a/lib/components/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/tracks_view/sections/body/track_view_body.dart @@ -15,6 +15,7 @@ import 'package:spotube/components/tracks_view/sections/body/track_view_body_hea import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart'; import 'package:spotube/components/tracks_view/track_view_props.dart'; import 'package:spotube/components/tracks_view/track_view_provider.dart'; +import 'package:spotube/extensions/list.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/history/history.dart'; @@ -96,7 +97,7 @@ class TrackViewBodySection extends HookConsumerWidget { ); } } else { - if (isActive || playlist.tracks.contains(track)) { + if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) { await playlistNotifier.jumpToTrack(track); } else { final tracks = await props.pagination.onFetchAll(); diff --git a/lib/extensions/list.dart b/lib/extensions/list.dart new file mode 100644 index 00000000..ddd36e4d --- /dev/null +++ b/lib/extensions/list.dart @@ -0,0 +1,19 @@ +extension UniqueItemExtension on List { + List unique(bool Function(T a, T b) equals) { + final copy = []; + + for (final item in this) { + if (copy.any((element) => equals(element, item))) continue; + copy.add(item); + } + + return copy; + } + + bool containsBy(T item, dynamic Function(T a) fn) { + for (final el in this) { + if (fn(el) == fn(item)) return true; + } + return false; + } +} diff --git a/lib/pages/track/track.dart b/lib/pages/track/track.dart index 6f3af0e4..84c53b74 100644 --- a/lib/pages/track/track.dart +++ b/lib/pages/track/track.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/track_tile/track_options.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/extensions/list.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -167,7 +168,8 @@ class TrackPage extends HookConsumerWidget { children: [ const Gap(5), if (!isActive && - !playlist.tracks.contains(track)) + !playlist.tracks + .containsBy(track, (t) => t.id)) OutlinedButton.icon( icon: const Icon(SpotubeIcons.queueAdd), label: Text(context.l10n.queue), @@ -177,7 +179,8 @@ class TrackPage extends HookConsumerWidget { ), const Gap(5), if (!isActive && - !playlist.tracks.contains(track)) + !playlist.tracks + .containsBy(track, (t) => t.id)) IconButton.outlined( icon: const Icon(SpotubeIcons.lightning), diff --git a/lib/provider/audio_player/audio_player.dart b/lib/provider/audio_player/audio_player.dart index 50e90dcd..7c1b6897 100644 --- a/lib/provider/audio_player/audio_player.dart +++ b/lib/provider/audio_player/audio_player.dart @@ -4,6 +4,7 @@ import 'package:drift/drift.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:media_kit/media_kit.dart' hide Track; import 'package:spotify/spotify.dart' hide Playlist; +import 'package:spotube/extensions/list.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/local_track.dart'; @@ -256,6 +257,10 @@ class AudioPlayerNotifier extends Notifier { for (int i = 0; i < tracks.length; i++) { final track = tracks.elementAt(i); + if (state.tracks.any((element) => _compareTracks(element, track))) { + continue; + } + await audioPlayer.addTrackAt( SpotubeMedia(track), max(state.playlist.index, 0) + i + 1, @@ -265,6 +270,7 @@ class AudioPlayerNotifier extends Notifier { Future addTrack(Track track) async { if (_blacklist.contains(track)) return; + if (state.tracks.any((element) => _compareTracks(element, track))) return; await audioPlayer.addTrack(SpotubeMedia(track)); } @@ -289,13 +295,23 @@ class AudioPlayerNotifier extends Notifier { } } + bool _compareTracks(Track a, Track b) { + if ((a is LocalTrack && b is! LocalTrack) || + (a is! LocalTrack && b is LocalTrack)) return false; + + return a is LocalTrack && b is LocalTrack + ? (a).path == (b).path + : a.id == b.id; + } + Future load( List tracks, { int initialIndex = 0, bool autoPlay = false, }) async { - final medias = - (_blacklist.filter(tracks).toList() as List).asMediaList(); + final medias = (_blacklist.filter(tracks).toList() as List) + .asMediaList() + .unique((a, b) => _compareTracks(a.track, b.track)); // Giving the initial track a boost so MediaKit won't skip // because of timeout diff --git a/pubspec.lock b/pubspec.lock index a1494a2d..3249c759 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -758,14 +758,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.5" - flutter_hooks_lint: - dependency: "direct dev" - description: - name: flutter_hooks_lint - sha256: fc6e18505b597737e5d620656e340ac60e7a58980cca29e18c1216bd15083674 - url: "https://pub.dev" - source: hosted - version: "1.2.0" flutter_inappwebview: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 402cd474..d69ab5db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -158,7 +158,6 @@ dev_dependencies: xml: ^6.5.0 io: ^1.0.4 drift_dev: ^2.18.0 - flutter_hooks_lint: ^1.2.0 dependency_overrides: uuid: ^4.4.0 From 5ff36a86433b82aa9e2ac54381b5b48b2c2d7f1e Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 14:53:41 +0600 Subject: [PATCH 237/261] fix(android): pressing back while the player is open doesn't take to previous page #1388 --- lib/components/framework/app_pop_scope.dart | 104 ++++++++++++++++++++ lib/modules/player/player.dart | 9 +- lib/pages/root/root_app.dart | 17 ++-- pubspec.lock | 4 +- pubspec.yaml | 2 +- 5 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 lib/components/framework/app_pop_scope.dart diff --git a/lib/components/framework/app_pop_scope.dart b/lib/components/framework/app_pop_scope.dart new file mode 100644 index 00000000..b8e35767 --- /dev/null +++ b/lib/components/framework/app_pop_scope.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +/// A temporary workaround for [WillPopScope] and [PopScope] not working in GoRouter +/// https://github.com/flutter/flutter/issues/140869#issuecomment-2247181468 +class AppPopScope extends StatefulWidget { + final Widget child; + + final PopInvokedCallback? onPopInvoked; + + final bool canPop; + + const AppPopScope({ + super.key, + required this.child, + this.canPop = true, + this.onPopInvoked, + }); + + @override + State createState() => _AppPopScopeState(); +} + +class _AppPopScopeState extends State { + final bool _enable = Platform.isAndroid; + ModalRoute? _route; + BackButtonDispatcher? _parentBackBtnDispatcher; + ChildBackButtonDispatcher? _backBtnDispatcher; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _route = ModalRoute.of(context); + _updateBackButtonDispatcher(); + } + + @override + void activate() { + super.activate(); + _updateBackButtonDispatcher(); + } + + @override + void deactivate() { + super.deactivate(); + _disposeBackBtnDispatcher(); + } + + @override + void dispose() { + _disposeBackBtnDispatcher(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: widget.canPop, + onPopInvoked: widget.onPopInvoked, + child: widget.child, + ); + } + + void _updateBackButtonDispatcher() { + if (!_enable) return; + + var dispatcher = Router.maybeOf(context)?.backButtonDispatcher; + if (dispatcher != _parentBackBtnDispatcher) { + _disposeBackBtnDispatcher(); + _parentBackBtnDispatcher = dispatcher; + if (dispatcher is BackButtonDispatcher && + dispatcher is! ChildBackButtonDispatcher) { + dispatcher = dispatcher.createChildBackButtonDispatcher(); + } + _backBtnDispatcher = dispatcher as ChildBackButtonDispatcher; + } + _backBtnDispatcher?.removeCallback(_handleBackButton); + _backBtnDispatcher?.addCallback(_handleBackButton); + _backBtnDispatcher?.takePriority(); + } + + void _disposeBackBtnDispatcher() { + _backBtnDispatcher?.removeCallback(_handleBackButton); + if (_backBtnDispatcher is ChildBackButtonDispatcher) { + final child = _backBtnDispatcher as ChildBackButtonDispatcher; + _parentBackBtnDispatcher?.forget(child); + } + _backBtnDispatcher = null; + _parentBackBtnDispatcher = null; + } + + bool get _onlyRoute => _route != null && _route!.isFirst && _route!.isCurrent; + + Future _handleBackButton() async { + if (_onlyRoute) { + widget.onPopInvoked?.call(widget.canPop); + if (!widget.canPop) { + return true; + } + } + return false; + } +} diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index 3202eeda..93aec5f9 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/assets.gen.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/framework/app_pop_scope.dart'; import 'package:spotube/modules/player/player_actions.dart'; import 'package:spotube/modules/player/player_controls.dart'; import 'package:spotube/modules/player/player_queue.dart'; @@ -100,11 +101,11 @@ class PlayerView extends HookConsumerWidget { final topPadding = MediaQueryData.fromView(View.of(context)).padding.top; - // ignore: deprecated_member_use - return WillPopScope( - onWillPop: () async { + return AppPopScope( + canPop: context.canPop(), + onPopInvoked: (didPop) async { + if (didPop) return; await panelController.close(); - return false; }, child: IconTheme( data: theme.iconTheme.copyWith(color: bodyTextColor), diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index f7aedf63..c48dbf37 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/framework/app_pop_scope.dart'; import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/modules/root/bottom_player.dart'; @@ -30,10 +31,12 @@ class RootApp extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { + final theme = Theme.of(context); + final routerState = GoRouterState.of(context); + final showingDialogCompleter = useRef(Completer()..complete()); final downloader = ref.watch(downloadManagerProvider); final scaffoldMessenger = ScaffoldMessenger.of(context); - final theme = Theme.of(context); final connectRoutes = ref.watch(serverConnectRoutesProvider); useEffect(() { @@ -164,15 +167,17 @@ class RootApp extends HookConsumerWidget { return null; }, [backgroundColor]); - // ignore: deprecated_member_use - return WillPopScope( - onWillPop: () async { + return AppPopScope( + // Only allow to pop when in root screen + canPop: routerState.namedLocation(HomePage.name) == + routerState.matchedLocation, + onPopInvoked: (didPop) async { + if (didPop) return; + final routerState = GoRouterState.of(context); if (routerState.matchedLocation != "/") { context.goNamed(HomePage.name); - return false; } - return true; }, child: Scaffold( body: Sidebar(child: child), diff --git a/pubspec.lock b/pubspec.lock index 3249c759..089563d8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1006,10 +1006,10 @@ packages: dependency: "direct main" description: name: go_router - sha256: c5fa45fa502ee880839e3b2152d987c44abae26d064a2376d4aad434cf0f7b15 + sha256: "2ddb88e9ad56ae15ee144ed10e33886777eb5ca2509a914850a5faa7b52ff459" url: "https://pub.dev" source: hosted - version: "12.1.3" + version: "14.2.7" google_fonts: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index d69ab5db..4972fc82 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -51,7 +51,6 @@ dependencies: flutter_svg: ^1.1.6 form_validator: ^2.1.1 fuzzywuzzy: ^1.1.6 - go_router: 12.1.3 # Stuck on this https://github.com/flutter/flutter/issues/140869 google_fonts: ^6.2.1 hive: ^2.2.3 hive_flutter: ^1.1.0 @@ -135,6 +134,7 @@ dependencies: sqlite3_flutter_libs: ^0.5.23 sqlite3: ^2.4.3 encrypt: ^5.0.3 + go_router: ^14.2.7 dev_dependencies: build_runner: ^2.4.9 From 1119c0e47da07e3a470c6149103da7021c5d82be Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 17:23:11 +0600 Subject: [PATCH 238/261] chore: pushed pages not closing --- lib/components/panels/controller.dart | 20 +++-- lib/modules/player/player.dart | 1 - lib/pages/root/root_app.dart | 104 ++++++++++++++------------ 3 files changed, 70 insertions(+), 55 deletions(-) diff --git a/lib/components/panels/controller.dart b/lib/components/panels/controller.dart index 834e9ce6..4e367701 100644 --- a/lib/components/panels/controller.dart +++ b/lib/components/panels/controller.dart @@ -41,29 +41,33 @@ class PanelController extends ChangeNotifier { bool get isAttached => _panelState != null; /// Closes the sliding panel to its collapsed state (i.e. to the minHeight) - Future close() { + Future close() async { assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._close(); + await _panelState!._close(); + notifyListeners(); } /// Opens the sliding panel fully /// (i.e. to the maxHeight) - Future open() { + Future open() async { assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._open(); + await _panelState!._open(); + notifyListeners(); } /// Hides the sliding panel (i.e. is invisible) - Future hide() { + Future hide() async { assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._hide(); + await _panelState!._hide(); + notifyListeners(); } /// Shows the sliding panel in its collapsed state /// (i.e. "un-hide" the sliding panel) - Future show() { + Future show() async { assert(isAttached, "PanelController must be attached to a SlidingUpPanel"); - return _panelState!._show(); + await _panelState!._show(); + notifyListeners(); } /// Animates the panel position to the value. diff --git a/lib/modules/player/player.dart b/lib/modules/player/player.dart index 93aec5f9..925afadc 100644 --- a/lib/modules/player/player.dart +++ b/lib/modules/player/player.dart @@ -104,7 +104,6 @@ class PlayerView extends HookConsumerWidget { return AppPopScope( canPop: context.canPop(), onPopInvoked: (didPop) async { - if (didPop) return; await panelController.close(); }, child: IconTheme( diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index c48dbf37..0274de00 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -5,6 +5,7 @@ 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:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/framework/app_pop_scope.dart'; import 'package:spotube/modules/player/player_queue.dart'; @@ -32,7 +33,6 @@ class RootApp extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final routerState = GoRouterState.of(context); final showingDialogCompleter = useRef(Completer()..complete()); final downloader = ref.watch(downloadManagerProvider); @@ -167,57 +167,69 @@ class RootApp extends HookConsumerWidget { return null; }, [backgroundColor]); + final navTileNames = useMemoized(() { + return getSidebarTileList(context.l10n).map((s) => s.name).toList(); + }, []); + + final scaffold = Scaffold( + body: Sidebar(child: child), + extendBody: true, + drawerScrimColor: Colors.transparent, + endDrawer: kIsDesktop + ? Container( + constraints: const BoxConstraints(maxWidth: 800), + decoration: BoxDecoration( + boxShadow: theme.brightness == Brightness.light + ? null + : kElevationToShadow[8], + ), + margin: const EdgeInsets.only( + top: 40, + bottom: 100, + ), + child: Consumer( + builder: (context, ref, _) { + final playlist = ref.watch(audioPlayerProvider); + final playlistNotifier = + ref.read(audioPlayerProvider.notifier); + + return PlayerQueue.fromAudioPlayerNotifier( + floating: true, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), + ) + : null, + bottomNavigationBar: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + BottomPlayer(), + SpotubeNavigationBar(), + ], + ), + ); + + if (!kIsAndroid) { + return scaffold; + } + + final topRoute = GoRouterState.of(context).topRoute; + final canPop = topRoute != null && !navTileNames.contains(topRoute.name); + return AppPopScope( - // Only allow to pop when in root screen - canPop: routerState.namedLocation(HomePage.name) == - routerState.matchedLocation, - onPopInvoked: (didPop) async { + canPop: canPop, + onPopInvoked: (didPop) { if (didPop) return; - final routerState = GoRouterState.of(context); - if (routerState.matchedLocation != "/") { + if (topRoute?.name == HomePage.name) { + SystemNavigator.pop(); + } else { context.goNamed(HomePage.name); } }, - child: Scaffold( - body: Sidebar(child: child), - extendBody: true, - drawerScrimColor: Colors.transparent, - endDrawer: kIsDesktop - ? Container( - constraints: const BoxConstraints(maxWidth: 800), - decoration: BoxDecoration( - boxShadow: theme.brightness == Brightness.light - ? null - : kElevationToShadow[8], - ), - margin: const EdgeInsets.only( - top: 40, - bottom: 100, - ), - child: Consumer( - builder: (context, ref, _) { - final playlist = ref.watch(audioPlayerProvider); - final playlistNotifier = - ref.read(audioPlayerProvider.notifier); - - return PlayerQueue.fromAudioPlayerNotifier( - floating: true, - playlist: playlist, - notifier: playlistNotifier, - ); - }, - ), - ) - : null, - bottomNavigationBar: const Column( - mainAxisSize: MainAxisSize.min, - children: [ - BottomPlayer(), - SpotubeNavigationBar(), - ], - ), - ), + child: scaffold, ); } } From 57c8f8573125d5964b8a81b67d64629e0e34fd82 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 17:45:57 +0600 Subject: [PATCH 239/261] fix: playlist displaying descriptions unescaped html #1784 --- lib/components/playbutton_card.dart | 2 +- .../tracks_view/sections/header/flexible_header.dart | 4 +++- lib/extensions/string.dart | 3 +++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/components/playbutton_card.dart b/lib/components/playbutton_card.dart index d540d31e..ae9050d8 100644 --- a/lib/components/playbutton_card.dart +++ b/lib/components/playbutton_card.dart @@ -58,7 +58,7 @@ class PlaybuttonCard extends HookWidget { others: 15, ); - var unescapeHtml = description?.unescapeHtml(); + final unescapeHtml = description?.unescapeHtml().cleanHtml(); return Container( constraints: BoxConstraints(maxWidth: size), margin: margin, diff --git a/lib/components/tracks_view/sections/header/flexible_header.dart b/lib/components/tracks_view/sections/header/flexible_header.dart index 6845cc3e..508d289c 100644 --- a/lib/components/tracks_view/sections/header/flexible_header.dart +++ b/lib/components/tracks_view/sections/header/flexible_header.dart @@ -128,7 +128,9 @@ class TrackViewFlexHeader extends HookConsumerWidget { if (props.description != null && props.description!.isNotEmpty) Text( - props.description!.unescapeHtml(), + props.description! + .unescapeHtml() + .cleanHtml(), style: defaultTextStyle.style.copyWith( color: palette.bodyTextColor, diff --git a/lib/extensions/string.dart b/lib/extensions/string.dart index d3706f3f..94123fe3 100644 --- a/lib/extensions/string.dart +++ b/lib/extensions/string.dart @@ -1,12 +1,15 @@ import 'package:html_unescape/html_unescape.dart'; +import 'package:html/parser.dart'; final htmlEscape = HtmlUnescape(); extension UnescapeHtml on String { + String cleanHtml() => parse("

$this

").documentElement!.text; String unescapeHtml() => htmlEscape.convert(this); } extension NullableUnescapeHtml on String? { + String? cleanHtml() => this?.cleanHtml(); String? unescapeHtml() => this?.unescapeHtml(); } From 1cad097d0b9ced105f2b4c935fb8f32035eb2287 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 18:22:09 +0600 Subject: [PATCH 240/261] fix(lyrics): LRCLIB lyrics should be usable without logging in #1803 --- lib/modules/root/bottom_player.dart | 57 ++++++++++++------------- lib/pages/lyrics/lyrics.dart | 11 ----- lib/pages/lyrics/mini_lyrics.dart | 12 +----- lib/pages/lyrics/synced_lyrics.dart | 2 +- lib/provider/spotify/lyrics/synced.dart | 7 ++- lib/provider/spotify/spotify.dart | 1 + 6 files changed, 35 insertions(+), 55 deletions(-) diff --git a/lib/modules/root/bottom_player.dart b/lib/modules/root/bottom_player.dart index 7f37c472..a2f45449 100644 --- a/lib/modules/root/bottom_player.dart +++ b/lib/modules/root/bottom_player.dart @@ -17,7 +17,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:flutter/material.dart'; -import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -30,7 +29,6 @@ class BottomPlayer extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final auth = ref.watch(authenticationProvider); final playlist = ref.watch(audioPlayerProvider); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); @@ -89,35 +87,34 @@ class BottomPlayer extends HookConsumerWidget { children: [ PlayerActions( extraActions: [ - if (auth.asData?.value != null) - IconButton( - tooltip: context.l10n.mini_player, - icon: const Icon(SpotubeIcons.miniPlayer), - onPressed: () async { - if (!kIsDesktop) return; + IconButton( + tooltip: context.l10n.mini_player, + icon: const Icon(SpotubeIcons.miniPlayer), + onPressed: () async { + if (!kIsDesktop) return; - final prevSize = await windowManager.getSize(); - await windowManager.setMinimumSize( - const Size(300, 300), - ); - await windowManager.setAlwaysOnTop(true); - if (!kIsLinux) { - await windowManager.setHasShadow(false); - } - await windowManager - .setAlignment(Alignment.topRight); - await windowManager.setSize(const Size(400, 500)); - await Future.delayed( - const Duration(milliseconds: 100), - () async { - GoRouter.of(context).go( - '/mini-player', - extra: prevSize, - ); - }, - ); - }, - ), + final prevSize = await windowManager.getSize(); + await windowManager.setMinimumSize( + const Size(300, 300), + ); + await windowManager.setAlwaysOnTop(true); + if (!kIsLinux) { + await windowManager.setHasShadow(false); + } + await windowManager + .setAlignment(Alignment.topRight); + await windowManager.setSize(const Size(400, 500)); + await Future.delayed( + const Duration(milliseconds: 100), + () async { + GoRouter.of(context).go( + '/mini-player', + extra: prevSize, + ); + }, + ); + }, + ), ], ), Container( diff --git a/lib/pages/lyrics/lyrics.dart b/lib/pages/lyrics/lyrics.dart index a81e3ba6..423212f3 100644 --- a/lib/pages/lyrics/lyrics.dart +++ b/lib/pages/lyrics/lyrics.dart @@ -6,7 +6,6 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/themed_button_tab_bar.dart'; @@ -17,7 +16,6 @@ import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/utils/use_palette_color.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; import 'package:spotube/pages/lyrics/synced_lyrics.dart'; -import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -82,15 +80,6 @@ class LyricsPage extends HookConsumerWidget { ), ); - final auth = ref.watch(authenticationProvider); - - if (auth.asData?.value == null) { - return Scaffold( - appBar: !kIsMacOS && !isModal ? const PageWindowTitleBar() : null, - body: const AnonymousFallback(), - ); - } - if (isModal) { return DefaultTabController( length: 2, diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index dbff563d..8f6ec1fc 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -8,13 +8,10 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/player/player_controls.dart'; import 'package:spotube/modules/player/player_queue.dart'; import 'package:spotube/modules/root/sidebar.dart'; -import 'package:spotube/components/fallbacks/anonymous_fallback.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/utils/use_force_update.dart'; import 'package:spotube/pages/lyrics/plain_lyrics.dart'; import 'package:spotube/pages/lyrics/synced_lyrics.dart'; -import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; @@ -46,14 +43,7 @@ class MiniLyricsPage extends HookConsumerWidget { return null; }, []); - final auth = ref.watch(authenticationProvider); - - if (auth.asData?.value == null) { - return const Scaffold( - appBar: PageWindowTitleBar(), - body: AnonymousFallback(), - ); - } + return MouseRegion( onEnter: !hoverMode.value diff --git a/lib/pages/lyrics/synced_lyrics.dart b/lib/pages/lyrics/synced_lyrics.dart index d7f7685a..59bd863a 100644 --- a/lib/pages/lyrics/synced_lyrics.dart +++ b/lib/pages/lyrics/synced_lyrics.dart @@ -82,7 +82,7 @@ class SyncedLyrics extends HookConsumerWidget { WidgetsBinding.instance.addPostFrameCallback((_) { subscription = audioPlayer.positionStream.listen((event) { try { - if (event > Duration.zero) return; + if (event > Duration.zero || !controller.hasClients) return; controller.animateTo( 0, duration: const Duration(milliseconds: 500), diff --git a/lib/provider/spotify/lyrics/synced.dart b/lib/provider/spotify/lyrics/synced.dart index 085fccb7..c6c0d6e3 100644 --- a/lib/provider/spotify/lyrics/synced.dart +++ b/lib/provider/spotify/lyrics/synced.dart @@ -125,6 +125,7 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier { try { final database = ref.watch(databaseProvider); final spotify = ref.watch(spotifyProvider); + final auth = await ref.watch(authenticationProvider.future); if (track == null) { throw "No track currently"; @@ -139,11 +140,13 @@ class SyncedLyricsNotifier extends FamilyAsyncNotifier { final token = await spotify.getCredentials(); - if (lyrics == null || lyrics.lyrics.isEmpty) { + if ((lyrics == null || lyrics.lyrics.isEmpty) && auth != null) { lyrics = await getSpotifyLyrics(token.accessToken); } - if (lyrics.lyrics.isEmpty || lyrics.lyrics.length <= 5) { + if (lyrics == null || + lyrics.lyrics.isEmpty || + lyrics.lyrics.length <= 5) { lyrics = await getLRCLibLyrics(); } diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index 5997a47a..8cf60120 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:drift/drift.dart'; import 'package:spotube/models/database/database.dart'; +import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/spotify/utils/json_cast.dart'; import 'package:spotube/services/logger/logger.dart'; From 02818593edf7e0e389c2ac3ea6660cde02f5eda4 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 18:37:22 +0600 Subject: [PATCH 241/261] chore: bump version to 3.8.1 and prepare for release --- .github/workflows/spotube-publish-binary.yml | 2 +- CHANGELOG.md | 26 ++++++++++++++++++++ lib/l10n/app_ar.arb | 5 +++- lib/l10n/app_bn.arb | 5 +++- lib/l10n/app_ca.arb | 5 +++- lib/l10n/app_cs.arb | 5 +++- lib/l10n/app_de.arb | 5 +++- lib/l10n/app_es.arb | 5 +++- lib/l10n/app_eu.arb | 5 +++- lib/l10n/app_fa.arb | 5 +++- lib/l10n/app_fi.arb | 5 +++- lib/l10n/app_fr.arb | 5 +++- lib/l10n/app_hi.arb | 5 +++- lib/l10n/app_id.arb | 5 +++- lib/l10n/app_it.arb | 5 +++- lib/l10n/app_ja.arb | 5 +++- lib/l10n/app_ka.arb | 5 +++- lib/l10n/app_ko.arb | 5 +++- lib/l10n/app_ne.arb | 5 +++- lib/l10n/app_nl.arb | 5 +++- lib/l10n/app_pl.arb | 5 +++- lib/l10n/app_pt.arb | 5 +++- lib/l10n/app_ru.arb | 5 +++- lib/l10n/app_th.arb | 5 +++- lib/l10n/app_tr.arb | 5 +++- lib/l10n/app_uk.arb | 5 +++- lib/l10n/app_vi.arb | 5 +++- lib/l10n/app_zh.arb | 5 +++- pubspec.yaml | 2 +- 29 files changed, 132 insertions(+), 28 deletions(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 812849ac..08ef7cf6 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.8.0 + default: 3.8.1 required: true dry_run: description: Dry run diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e434574..fa507e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.8.1](https://github.com/krtirtho/spotube/compare/v3.8.0...v3.8.1) (2024-09-15) + +## Changes + +### Bug Fixes + +- **lyrics**: LRCLIB lyrics should be usable without logging in #1803 +- playlist displaying descriptions unescaped html #1784 +- **android**: pressing back while the player is open doesn't take to previous page +- handle dublicated items in playback queue correctly #1852 +- **desktop**: scrollbar overlapping with more options of tracks and playlists +- **discord**: stop discord rpc from try update presence when not connected +- **stats**: minutes page shows plays and streams page shows minutes which should be the opposite #1880 +- **android**: clears queue upon swiping away notification +- **player**: shuffle button state resets after closing page #1657 +- getting started page login page exception #1800 +- **mobile**: queue doesn't persist +- local tracks takes time to load +- start radio not working #1629 + +### Features + +- **desktop**: show error dialog if webview is not found on login #1871 +- manually detect and define touch behavior #1763 + + ## [3.8.0](https://github.com/krtirtho/spotube/compare/v3.7.1...v3.8.0) (2024-06-06) ### Features diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index a962b41b..141e10f0 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "حصلت على حبك", "summary_playlists": "قوائم التشغيل", "summary_were_on_repeat": "كانت على التكرار", - "total_money": "المجموع {money}" + "total_money": "المجموع {money}", + "webview_not_found": "لم يتم العثور على Webview", + "webview_not_found_description": "لم يتم تثبيت بيئة تشغيل Webview على جهازك.\nإذا كانت مثبتة، تأكد من وجودها في environment PATH\n\nبعد التثبيت، أعد تشغيل التطبيق", + "unsupported_platform": "المنصة غير مدعومة" } \ No newline at end of file diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 97872c8c..ae088b45 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "আপনার ভালোবাসা পেয়েছে", "summary_playlists": "প্লেলিস্ট", "summary_were_on_repeat": "পুনরাবৃত্তিতে ছিল", - "total_money": "মোট {money}" + "total_money": "মোট {money}", + "webview_not_found": "ওয়েবভিউ পাওয়া যায়নি", + "webview_not_found_description": "আপনার ডিভাইসে কোনো ওয়েবভিউ রানটাইম ইনস্টল করা নেই।\nযদি ইনস্টল থাকে, তা নিশ্চিত করুন যে এটি environment PATH এ রয়েছে\n\nইনস্টল করার পর, অ্যাপটি পুনরায় চালু করুন", + "unsupported_platform": "সমর্থিত প্ল্যাটফর্ম নয়" } \ No newline at end of file diff --git a/lib/l10n/app_ca.arb b/lib/l10n/app_ca.arb index 2cda6e88..58805e62 100644 --- a/lib/l10n/app_ca.arb +++ b/lib/l10n/app_ca.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "ha aconseguit el teu amor", "summary_playlists": "llistes de reproducció", "summary_were_on_repeat": "estaven en repetició", - "total_money": "total {money}" + "total_money": "total {money}", + "webview_not_found": "No s'ha trobat el Webview", + "webview_not_found_description": "No hi ha cap temps d'execució de Webview instal·lat al dispositiu.\nSi està instal·lat, assegureu-vos que estigui en el environment PATH\n\nDesprés d'instal·lar-lo, reinicieu l'aplicació", + "unsupported_platform": "Plataforma no compatible" } \ No newline at end of file diff --git a/lib/l10n/app_cs.arb b/lib/l10n/app_cs.arb index b1a22ee2..99ee0962 100644 --- a/lib/l10n/app_cs.arb +++ b/lib/l10n/app_cs.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "Získal vaši lásku", "summary_playlists": "playlisty", "summary_were_on_repeat": "Byly na opakování", - "total_money": "Celkem {money}" + "total_money": "Celkem {money}", + "webview_not_found": "Webview nebyl nalezen", + "webview_not_found_description": "Na vašem zařízení není nainstalováno žádné runtime prostředí Webview.\nPokud je nainstalováno, ujistěte se, že je v environment PATH\n\nPo instalaci restartujte aplikaci", + "unsupported_platform": "Nepodporovaná platforma" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 4b9495aa..36da0b3e 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "Hat Ihre Liebe gewonnen", "summary_playlists": "Wiedergabelisten", "summary_were_on_repeat": "Wurden wiederholt", - "total_money": "Gesamt {money}" + "total_money": "Gesamt {money}", + "webview_not_found": "Webview nicht gefunden", + "webview_not_found_description": "Es ist keine Webview-Laufzeitumgebung auf Ihrem Gerät installiert.\nFalls installiert, stellen Sie sicher, dass es im environment PATH ist\n\nNach der Installation starten Sie die App neu", + "unsupported_platform": "Nicht unterstützte Plattform" } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 6834d845..d3c8b389 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "Obtuvo tu amor", "summary_playlists": "listas de reproducción", "summary_were_on_repeat": "Estaban en repetición", - "total_money": "Total {money}" + "total_money": "Total {money}", + "webview_not_found": "No se encontró el Webview", + "webview_not_found_description": "No hay tiempo de ejecución de Webview instalado en su dispositivo.\nSi está instalado, asegúrese de que esté en el environment PATH\n\nDespués de instalar, reinicie la aplicación", + "unsupported_platform": "Plataforma no soportada" } \ No newline at end of file diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb index 6cc41620..b590a88e 100644 --- a/lib/l10n/app_eu.arb +++ b/lib/l10n/app_eu.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "Izan dute zure maitasuna", "summary_playlists": "zerrenda", "summary_were_on_repeat": "Dituzu errepikatze moduan", - "total_money": "Guztira {money}" + "total_money": "Guztira {money}", + "webview_not_found": "Ez da Webview aurkitu", + "webview_not_found_description": "Ez dago Webview abiarazte denbora-instalaziorik zure gailuan.\nInstalatuta badago, ziurtatu environment PATH-an dagoela\n\nInstalatu ondoren, berrabiarazi aplikazioa", + "unsupported_platform": "Plataforma ez onartua" } \ No newline at end of file diff --git a/lib/l10n/app_fa.arb b/lib/l10n/app_fa.arb index 5611e0cc..47242a04 100644 --- a/lib/l10n/app_fa.arb +++ b/lib/l10n/app_fa.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "عشق شما را به دست آورد", "summary_playlists": "لیست‌های پخش", "summary_were_on_repeat": "در تکرار بودند", - "total_money": "مجموع {money}" + "total_money": "مجموع {money}", + "webview_not_found": "وب‌ویو پیدا نشد", + "webview_not_found_description": "هیچ اجرای وب‌ویو روی دستگاه شما نصب نشده است.\nدر صورت نصب، مطمئن شوید که در environment PATH قرار دارد\n\nپس از نصب، برنامه را مجدداً راه‌اندازی کنید", + "unsupported_platform": "پلتفرم پشتیبانی نمی‌شود" } \ No newline at end of file diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index 57f209ab..53b948a6 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "Sai rakkautesi", "summary_playlists": "soittolistat", "summary_were_on_repeat": "Olivat toistossa", - "total_money": "Yhteensä {money}" + "total_money": "Yhteensä {money}", + "webview_not_found": "Webview ei löydy", + "webview_not_found_description": "Laitteellasi ei ole asennettua Webview-ajonaikaa.\nJos se on asennettu, varmista, että se on environment PATH:ssa\n\nAsennuksen jälkeen käynnistä sovellus uudelleen", + "unsupported_platform": "Ei tuettu alusta" } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 4a41dec9..522a2af4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "A obtenu votre amour", "summary_playlists": "playlists", "summary_were_on_repeat": "Était en répétition", - "total_money": "Total {money}" + "total_money": "Total {money}", + "webview_not_found": "Webview non trouvé", + "webview_not_found_description": "Aucun environnement d'exécution Webview installé sur votre appareil.\nSi c'est installé, assurez-vous qu'il soit dans le environment PATH\n\nAprès l'installation, redémarrez l'application", + "unsupported_platform": "Plateforme non prise en charge" } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a65e3f75..ce01aebe 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -384,5 +384,8 @@ "count_streams": "{count} स्ट्रिम", "owned_by_you": "तपाईंले स्वामित्व गरेको", "copied_shareurl_to_clipboard": "{shareUrl} क्लिपबोर्डमा कपी गरियो", - "spotify_hipotetical_calculation": "*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n$0.003 देखि $0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।" + "spotify_hipotetical_calculation": "*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n$0.003 देखि $0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।", + "webview_not_found": "वेबव्यू नहीं मिला", + "webview_not_found_description": "आपके डिवाइस पर वेबव्यू रनटाइम इंस्टॉल नहीं है।\nअगर इंस्टॉल है, तो सुनिश्चित करें कि यह environment PATH में है\n\nइंस्टॉल करने के बाद, ऐप को पुनः शुरू करें", + "unsupported_platform": "असमर्थित प्लेटफार्म" } \ No newline at end of file diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 0a417c40..121695f4 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -384,5 +384,8 @@ "summary_got_your_love": "Mendapatkan cinta Anda", "summary_playlists": "daftar putar", "summary_were_on_repeat": "Sedang diulang", - "total_money": "Total {money}" + "total_money": "Total {money}", + "webview_not_found": "Webview tidak ditemukan", + "webview_not_found_description": "Tidak ada runtime Webview yang diinstal di perangkat Anda.\nJika sudah diinstal, pastikan itu ada di environment PATH\n\nSetelah diinstal, restart aplikasi", + "unsupported_platform": "Platform tidak didukung" } \ No newline at end of file diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 6cbcbb6a..3a2c57c3 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -385,5 +385,8 @@ "summary_got_your_love": "Ha ricevuto il tuo amore", "summary_playlists": "playlist", "summary_were_on_repeat": "Erano in ripetizione", - "total_money": "Totale {money}" + "total_money": "Totale {money}", + "webview_not_found": "Webview non trovato", + "webview_not_found_description": "Nessun runtime Webview installato nel tuo dispositivo.\nSe è installato, assicurati che sia nel environment PATH\n\nDopo l'installazione, riavvia l'app", + "unsupported_platform": "Piattaforma non supportata" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index a26c8ba0..ed779478 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -384,5 +384,8 @@ "count_streams": "{count} 回のストリーム", "owned_by_you": "あなたが所有", "copied_shareurl_to_clipboard": "{shareUrl} をクリップボードにコピーしました", - "spotify_hipotetical_calculation": "*これは、Spotifyのストリームごとの支払い\nが $0.003 から $0.005 の範囲で計算されています。これは仮想的な\n計算で、Spotify で曲を聴いた場合に、アーティストに\nどれくらい支払ったかをユーザーに示すためのものです。" + "spotify_hipotetical_calculation": "*これは、Spotifyのストリームごとの支払い\nが $0.003 から $0.005 の範囲で計算されています。これは仮想的な\n計算で、Spotify で曲を聴いた場合に、アーティストに\nどれくらい支払ったかをユーザーに示すためのものです。", + "webview_not_found": "Webviewが見つかりません", + "webview_not_found_description": "デバイスにWebviewランタイムがインストールされていません。\nインストールされている場合は、environment PATHにあることを確認してください\n\nインストール後、アプリを再起動してください", + "unsupported_platform": "サポートされていないプラットフォーム" } \ No newline at end of file diff --git a/lib/l10n/app_ka.arb b/lib/l10n/app_ka.arb index 66d7f888..888dbb6f 100644 --- a/lib/l10n/app_ka.arb +++ b/lib/l10n/app_ka.arb @@ -384,5 +384,8 @@ "count_streams": "{count} სტრიმი", "owned_by_you": "შენ მიერ საკუთრებული", "copied_shareurl_to_clipboard": "{shareUrl} აიღო კლიპბორდზე", - "spotify_hipotetical_calculation": "*ეს გამოითვლება Spotify-ის თითოეულ სტრიმზე\nგადახდის შესაბამისად, რომელიც $0.003 დან $0.005-მდეა. ეს არის ჰიპოთეტური\nგამოთვლა, რომელიც აჩვენებს მომხმარებელს რამდენი გადაიხდიდა\nარტისტებს, თუკი ისინი უსმენდნენ მათ სიმღერებს Spotify-ზე." + "spotify_hipotetical_calculation": "*ეს გამოითვლება Spotify-ის თითოეულ სტრიმზე\nგადახდის შესაბამისად, რომელიც $0.003 დან $0.005-მდეა. ეს არის ჰიპოთეტური\nგამოთვლა, რომელიც აჩვენებს მომხმარებელს რამდენი გადაიხდიდა\nარტისტებს, თუკი ისინი უსმენდნენ მათ სიმღერებს Spotify-ზე.", + "webview_not_found": "ვებვიუ ვერ მოიძებნა", + "webview_not_found_description": "თქვენს მოწყობილობაზე ვებვიუის შესრულების დრო არ არის დაყენებული.\nთუ დაყენებულია, დარწმუნდით, რომ ის environment PATH-შია\n\nდაყენების შემდეგ, გადატვირთეთ აპი", + "unsupported_platform": "მოუხერხებელი პლატფორმა" } \ No newline at end of file diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 10036ba5..a71b59ae 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -385,5 +385,8 @@ "count_streams": "{count} 스트림", "owned_by_you": "당신이 소유", "copied_shareurl_to_clipboard": "{shareUrl}를 클립보드에 복사했습니다", - "spotify_hipotetical_calculation": "*Spotify의 스트림당 지불금 $0.003에서 $0.005까지의\n기준으로 계산되었습니다. 이는 사용자가 Spotify에서\n곡을 들을 때 아티스트에게 얼마를 지불했을지를\n알려주기 위한 가상의 계산입니다." + "spotify_hipotetical_calculation": "*Spotify의 스트림당 지불금 $0.003에서 $0.005까지의\n기준으로 계산되었습니다. 이는 사용자가 Spotify에서\n곡을 들을 때 아티스트에게 얼마를 지불했을지를\n알려주기 위한 가상의 계산입니다.", + "webview_not_found": "웹뷰를 찾을 수 없음", + "webview_not_found_description": "기기에 웹뷰 런타임이 설치되지 않았습니다.\n설치되어 있으면 environment PATH에 있는지 확인하십시오\n\n설치 후 앱을 다시 시작하세요", + "unsupported_platform": "지원되지 않는 플랫폼" } \ No newline at end of file diff --git a/lib/l10n/app_ne.arb b/lib/l10n/app_ne.arb index ce2a1e4b..9bcfebad 100644 --- a/lib/l10n/app_ne.arb +++ b/lib/l10n/app_ne.arb @@ -384,5 +384,8 @@ "count_streams": "{count} स्ट्रिम", "owned_by_you": "तपाईंले स्वामित्व गरेको", "copied_shareurl_to_clipboard": "{shareUrl} क्लिपबोर्डमा कपी गरियो", - "spotify_hipotetical_calculation": "*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n$0.003 देखि $0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।" + "spotify_hipotetical_calculation": "*यो Spotify को प्रति स्ट्रीम भुगतानको आधारमा\n$0.003 देखि $0.005 को बीचमा गणना गरिएको हो। यो एक काल्पनिक\nगणना हो जसले प्रयोगकर्तालाई देखाउँछ कि उनीहरूले कति\nअर्टिस्टहरूलाई तिनीहरूका गीतहरू Spotify मा सुनेमा\nभुक्तान गर्नुपर्ने थियो।", + "webview_not_found": "वेबभ्यू फेला परेन", + "webview_not_found_description": "तपाईंको उपकरणमा कुनै वेबभ्यू रनटाइम स्थापना गरिएको छैन।\nयदि स्थापना गरिएको छ भने, environment PATH मा छ कि छैन भनेर सुनिश्चित गर्नुहोस्\n\nस्थापना पछि, अनुप्रयोग पुनः सुरु गर्नुहोस्", + "unsupported_platform": "असमर्थित प्लेटफार्म" } \ No newline at end of file diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 5e22446d..93ab02a1 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -385,5 +385,8 @@ "count_streams": "{count} streams", "owned_by_you": "Bezit door jou", "copied_shareurl_to_clipboard": "{shareUrl} gekopieerd naar klembord", - "spotify_hipotetical_calculation": "*Dit is berekend op basis van Spotify's betaling per stream\nvan $0.003 tot $0.005. Dit is een hypothetische\nberekening om de gebruiker inzicht te geven in hoeveel ze\naan de artiesten zouden hebben betaald als ze hun liedjes op Spotify\nzouden luisteren." + "spotify_hipotetical_calculation": "*Dit is berekend op basis van Spotify's betaling per stream\nvan $0.003 tot $0.005. Dit is een hypothetische\nberekening om de gebruiker inzicht te geven in hoeveel ze\naan de artiesten zouden hebben betaald als ze hun liedjes op Spotify\nzouden luisteren.", + "webview_not_found": "Webview niet gevonden", + "webview_not_found_description": "Er is geen Webview-runtime geïnstalleerd op uw apparaat.\nAls het is geïnstalleerd, zorg ervoor dat het in het environment PATH staat\n\nHerstart de app na installatie", + "unsupported_platform": "Niet ondersteund platform" } \ No newline at end of file diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 06449ad9..c003ef08 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -384,5 +384,8 @@ "count_streams": "{count} strumieni", "owned_by_you": "Własność Twoja", "copied_shareurl_to_clipboard": "{shareUrl} skopiowano do schowka", - "spotify_hipotetical_calculation": "*Obliczone na podstawie płatności Spotify za strumień\nw zakresie od $0.003 do $0.005. Jest to hipotetyczne\nobliczenie mające na celu pokazanie użytkownikowi, ile\nzapłaciliby artystom, gdyby słuchali ich utworów na Spotify." + "spotify_hipotetical_calculation": "*Obliczone na podstawie płatności Spotify za strumień\nw zakresie od $0.003 do $0.005. Jest to hipotetyczne\nobliczenie mające na celu pokazanie użytkownikowi, ile\nzapłaciliby artystom, gdyby słuchali ich utworów na Spotify.", + "webview_not_found": "Nie znaleziono Webview", + "webview_not_found_description": "Na twoim urządzeniu nie zainstalowano środowiska uruchomieniowego Webview.\nJeśli jest zainstalowany, upewnij się, że jest w environment PATH\n\nPo instalacji uruchom ponownie aplikację", + "unsupported_platform": "Nieobsługiwana platforma" } \ No newline at end of file diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 7231d15a..02772b1e 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -384,5 +384,8 @@ "count_streams": "{count} streams", "owned_by_you": "De sua propriedade", "copied_shareurl_to_clipboard": "{shareUrl} copiado para a área de transferência", - "spotify_hipotetical_calculation": "*Isso é calculado com base no pagamento por stream do Spotify\nque varia de $0.003 a $0.005. Esta é uma cálculo hipotético\npara dar ao usuário uma visão de quanto teriam pago aos artistas\nse eles ouvissem suas músicas no Spotify." + "spotify_hipotetical_calculation": "*Isso é calculado com base no pagamento por stream do Spotify\nque varia de $0.003 a $0.005. Esta é uma cálculo hipotético\npara dar ao usuário uma visão de quanto teriam pago aos artistas\nse eles ouvissem suas músicas no Spotify.", + "webview_not_found": "Webview não encontrado", + "webview_not_found_description": "Nenhum runtime Webview está instalado no seu dispositivo.\nSe estiver instalado, certifique-se de que está no environment PATH\n\nApós a instalação, reinicie o aplicativo", + "unsupported_platform": "Plataforma não suportada" } \ No newline at end of file diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7cffb42a..189e644f 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -384,5 +384,8 @@ "count_streams": "{count} стримов", "owned_by_you": "Ваша собственность", "copied_shareurl_to_clipboard": "{shareUrl} скопировано в буфер обмена", - "spotify_hipotetical_calculation": "*Это рассчитано на основе выплат Spotify за стрим\nот $0.003 до $0.005. Это гипотетический расчет,\nчтобы дать пользователю представление о том, сколько бы он\nзаплатил артистам, если бы слушал их песни на Spotify." + "spotify_hipotetical_calculation": "*Это рассчитано на основе выплат Spotify за стрим\nот $0.003 до $0.005. Это гипотетический расчет,\nчтобы дать пользователю представление о том, сколько бы он\nзаплатил артистам, если бы слушал их песни на Spotify.", + "webview_not_found": "Webview не найден", + "webview_not_found_description": "На вашем устройстве не установлена среда выполнения Webview.\nЕсли он установлен, убедитесь, что он находится в environment PATH\n\nПосле установки перезапустите приложение", + "unsupported_platform": "Платформа не поддерживается" } \ No newline at end of file diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 3cac73f7..27c05a5d 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -385,5 +385,8 @@ "count_streams": "{count} สตรีม", "owned_by_you": "เป็นเจ้าของโดยคุณ", "copied_shareurl_to_clipboard": "{shareUrl} คัดลอกไปที่คลิปบอร์ดแล้ว", - "spotify_hipotetical_calculation": "*คำนวณตามการจ่ายต่อสตรีมของ Spotify\nซึ่งอยู่ในช่วง $0.003 ถึง $0.005 นี่เป็นการคำนวณสมมุติ\nเพื่อให้ผู้ใช้ทราบว่าพวกเขาจะจ่ายเงินให้ศิลปินเท่าไหร่\nหากพวกเขาฟังเพลงของพวกเขาใน Spotify." + "spotify_hipotetical_calculation": "*คำนวณตามการจ่ายต่อสตรีมของ Spotify\nซึ่งอยู่ในช่วง $0.003 ถึง $0.005 นี่เป็นการคำนวณสมมุติ\nเพื่อให้ผู้ใช้ทราบว่าพวกเขาจะจ่ายเงินให้ศิลปินเท่าไหร่\nหากพวกเขาฟังเพลงของพวกเขาใน Spotify.", + "webview_not_found": "ไม่พบ Webview", + "webview_not_found_description": "ไม่พบ runtime ของ Webview บนอุปกรณ์ของคุณ\nหากติดตั้งแล้วตรวจสอบให้แน่ใจว่าอยู่ใน environment PATH\n\nหลังจากติดตั้งแล้ว ให้รีสตาร์ทแอป", + "unsupported_platform": "แพลตฟอร์มไม่รองรับ" } \ No newline at end of file diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index b5a0ec1e..230f14e8 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -384,5 +384,8 @@ "count_streams": "{count} yayın", "owned_by_you": "Sahip olduğunuz", "copied_shareurl_to_clipboard": "{shareUrl} panoya kopyalandı", - "spotify_hipotetical_calculation": "*Bu, Spotify'ın her yayın başına ödemenin\n$0.003 ile $0.005 arasında olduğu varsayımıyla hesaplanmıştır. Bu\nhipotetik bir hesaplamadır, kullanıcıya şarkılarını Spotify'da dinlediklerinde\nsanatçılara ne kadar ödeme yapacaklarını gösterir." + "spotify_hipotetical_calculation": "*Bu, Spotify'ın her yayın başına ödemenin\n$0.003 ile $0.005 arasında olduğu varsayımıyla hesaplanmıştır. Bu\nhipotetik bir hesaplamadır, kullanıcıya şarkılarını Spotify'da dinlediklerinde\nsanatçılara ne kadar ödeme yapacaklarını gösterir.", + "webview_not_found": "Webview bulunamadı", + "webview_not_found_description": "Cihazınızda herhangi bir Webview çalışma zamanı yüklü değil.\nEğer kuruluysa, ortam YOLUNDA olduğundan emin olun\n\nKurulumdan sonra uygulamayı yeniden başlatın", + "unsupported_platform": "Desteklenmeyen platform" } \ No newline at end of file diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index 013a64b7..0c65f756 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -384,5 +384,8 @@ "count_streams": "{count} стримів", "owned_by_you": "Ваша власність", "copied_shareurl_to_clipboard": "{shareUrl} скопійовано в буфер обміну", - "spotify_hipotetical_calculation": "*Це розраховано на основі виплат Spotify за стрім\nвід $0.003 до $0.005. Це гіпотетичний розрахунок,\nщоб дати користувачеві уявлення про те, скільки б він заплатив\nартистам, якби слухав їхні пісні на Spotify." + "spotify_hipotetical_calculation": "*Це розраховано на основі виплат Spotify за стрім\nвід $0.003 до $0.005. Це гіпотетичний розрахунок,\nщоб дати користувачеві уявлення про те, скільки б він заплатив\nартистам, якби слухав їхні пісні на Spotify.", + "webview_not_found": "Webview не знайдено", + "webview_not_found_description": "На вашому пристрої не встановлено виконуване середовище Webview.\nЯкщо воно встановлено, переконайтеся, що воно знаходиться в environment PATH\n\nПісля встановлення перезапустіть програму", + "unsupported_platform": "Непідтримувана платформа" } \ No newline at end of file diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 5791793e..75dc1532 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -384,5 +384,8 @@ "count_streams": "{count} lượt phát", "owned_by_you": "Thuộc sở hữu của bạn", "copied_shareurl_to_clipboard": "{shareUrl} đã sao chép vào bảng tạm", - "spotify_hipotetical_calculation": "*Được tính toán dựa trên khoản thanh toán của Spotify cho mỗi lượt phát\ntừ $0.003 đến $0.005. Đây là một tính toán giả định để\ncung cấp cho người dùng cái nhìn về số tiền họ sẽ phải trả\ncho các nghệ sĩ nếu họ nghe bài hát của họ trên Spotify." + "spotify_hipotetical_calculation": "*Được tính toán dựa trên khoản thanh toán của Spotify cho mỗi lượt phát\ntừ $0.003 đến $0.005. Đây là một tính toán giả định để\ncung cấp cho người dùng cái nhìn về số tiền họ sẽ phải trả\ncho các nghệ sĩ nếu họ nghe bài hát của họ trên Spotify.", + "webview_not_found": "Không tìm thấy Webview", + "webview_not_found_description": "Không có runtime Webview nào được cài đặt trên thiết bị của bạn.\nNếu đã cài đặt, hãy đảm bảo rằng nó nằm trong environment PATH\n\nSau khi cài đặt, hãy khởi động lại ứng dụng", + "unsupported_platform": "Nền tảng không được hỗ trợ" } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 91447213..c9bf35df 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -384,5 +384,8 @@ "count_streams": "{count} 次流媒体", "owned_by_you": "由您拥有", "copied_shareurl_to_clipboard": "{shareUrl} 已复制到剪贴板", - "spotify_hipotetical_calculation": "*根据 Spotify 每次流媒体的支付金额\n$0.003 到 $0.005 进行计算。这是一个假设性的\n计算,用于给用户了解他们如果在 Spotify 上\n收听歌曲会支付给艺术家的金额。" + "spotify_hipotetical_calculation": "*根据 Spotify 每次流媒体的支付金额\n$0.003 到 $0.005 进行计算。这是一个假设性的\n计算,用于给用户了解他们如果在 Spotify 上\n收听歌曲会支付给艺术家的金额。", + "webview_not_found": "未找到 Webview", + "webview_not_found_description": "您的设备中未安装 Webview 运行时。\n如果已安装,请确保它在 environment PATH 中\n\n安装后,重新启动应用程序", + "unsupported_platform": "不支持的平台" } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 4972fc82..e4face3c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.8.0+33 +version: 3.8.1+34 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube From c680aeb1f481efdbe546624f885597f3d60bfcef Mon Sep 17 00:00:00 2001 From: Josu Igoa Date: Sun, 15 Sep 2024 14:46:17 +0200 Subject: [PATCH 242/261] fix(translations): correct some basque incorrect translations (#1815) --- lib/l10n/app_eu.arb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/l10n/app_eu.arb b/lib/l10n/app_eu.arb index b590a88e..36986804 100644 --- a/lib/l10n/app_eu.arb +++ b/lib/l10n/app_eu.arb @@ -367,7 +367,7 @@ "count_plays": "{count} erreprodukzio", "streaming_fees_hypothetical": "Streaming ordainketa (hipotetikoa)", "minutes_listened": "Entzundako minutuak", - "streamed_songs": "Stream-eatutako kantak", + "streamed_songs": "Streaming-ez entzundako kantak", "count_streams": "{count} stream", "owned_by_you": "Zure jabetzakoa", "copied_shareurl_to_clipboard": "{shareUrl} arbelera kopiatua", @@ -376,12 +376,12 @@ "summary_minutes": "minutu", "summary_listened_to_music": "Musika entzuten", "summary_songs": "kanta", - "summary_streamed_overall": "Stream-eatuta oro har", + "summary_streamed_overall": "Streaming abesti oro har", "summary_owed_to_artists": "Hilabete honetan\nartistei zor zaiena", "summary_artists": "artisten", "summary_music_reached_you": "Musika ailegatu zaizu", "summary_full_albums": "album osok", - "summary_got_your_love": "Izan dute zure maitasuna", + "summary_got_your_love": "Jaso dute zure maitasuna", "summary_playlists": "zerrenda", "summary_were_on_repeat": "Dituzu errepikatze moduan", "total_money": "Guztira {money}", From c9d6d2cd487800795e0be22411b19961b6b1961f Mon Sep 17 00:00:00 2001 From: AdrienC Date: Sun, 15 Sep 2024 08:49:13 -0400 Subject: [PATCH 243/261] chore(windows): add smal logo image in inno setup installer (#1795) --- windows/packaging/exe/inno_setup.iss | 1 + windows/packaging/exe/spotube-logo.bmp | Bin 0 -> 9294 bytes 2 files changed, 1 insertion(+) create mode 100644 windows/packaging/exe/spotube-logo.bmp diff --git a/windows/packaging/exe/inno_setup.iss b/windows/packaging/exe/inno_setup.iss index dbb8082b..4d0035c9 100644 --- a/windows/packaging/exe/inno_setup.iss +++ b/windows/packaging/exe/inno_setup.iss @@ -20,6 +20,7 @@ Compression=lzma SolidCompression=yes SetupIconFile={{SETUP_ICON_FILE}} WizardStyle=modern +WizardSmallImageFile=spotube-logo.bmp PrivilegesRequired={{PRIVILEGES_REQUIRED}} ArchitecturesAllowed=x64 ArchitecturesInstallIn64BitMode=x64 diff --git a/windows/packaging/exe/spotube-logo.bmp b/windows/packaging/exe/spotube-logo.bmp new file mode 100644 index 0000000000000000000000000000000000000000..c3503e85c5d044c8d035bc7b967df64d78b4ce85 GIT binary patch literal 9294 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 LpSS$+XTbTt?!EAP literal 0 HcmV?d00001 From 7f23c9f8f69fa69fcdb12c814fd28800a7ee4338 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 18:52:01 +0600 Subject: [PATCH 244/261] chore: untranslated messages cleared --- untranslated_messages.json | 158 +------------------------------------ 1 file changed, 1 insertion(+), 157 deletions(-) diff --git a/untranslated_messages.json b/untranslated_messages.json index 01124edd..9e26dfee 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,157 +1 @@ -{ - "ar": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "bn": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "ca": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "cs": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "de": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "es": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "eu": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "fa": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "fi": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "fr": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "hi": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "id": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "it": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "ja": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "ka": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "ko": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "ne": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "nl": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "pl": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "pt": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "ru": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "th": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "tr": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "uk": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "vi": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ], - - "zh": [ - "webview_not_found", - "webview_not_found_description", - "unsupported_platform" - ] -} +{} \ No newline at end of file From 68ca6a7d31117cffdfd169c14bc58d48401546f7 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 18:53:10 +0600 Subject: [PATCH 245/261] chore: add some more changes --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa507e9b..20b48c26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. See [standa ### Bug Fixes +- **translations**: correct some basque incorrect translations (#1815) - **lyrics**: LRCLIB lyrics should be usable without logging in #1803 - playlist displaying descriptions unescaped html #1784 - **android**: pressing back while the player is open doesn't take to previous page From 5ac534619f2df1c8f4f7be289c301ee2a534e8c9 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 15 Sep 2024 19:10:05 +0600 Subject: [PATCH 246/261] chore(windows): bmp file not found --- .../packaging/exe => assets}/spotube-logo.bmp | Bin scripts/windows-setup-creator.iss | 59 ------------------ windows/packaging/exe/inno_setup.iss | 2 +- 3 files changed, 1 insertion(+), 60 deletions(-) rename {windows/packaging/exe => assets}/spotube-logo.bmp (100%) delete mode 100644 scripts/windows-setup-creator.iss diff --git a/windows/packaging/exe/spotube-logo.bmp b/assets/spotube-logo.bmp similarity index 100% rename from windows/packaging/exe/spotube-logo.bmp rename to assets/spotube-logo.bmp diff --git a/scripts/windows-setup-creator.iss b/scripts/windows-setup-creator.iss deleted file mode 100644 index 93302234..00000000 --- a/scripts/windows-setup-creator.iss +++ /dev/null @@ -1,59 +0,0 @@ -; Script generated by the Inno Setup Script Wizard. -; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! - -#define MyAppName "Spotube" -#define MyAppVersion "2.0.0" -#define MyAppPublisher "KRTirtho, OSS" -#define MyAppURL "https://github.com/KRTirtho/spotube" -#define MyAppExeName "spotube.exe" - -[Setup] -; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. -; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) -AppId={{80B901C8-D6FE-494E-8AF7-A2BD440E8644} -AppName={#MyAppName} -AppVersion={#MyAppVersion} -;AppVerName={#MyAppName} {#MyAppVersion} -AppPublisher={#MyAppPublisher} -AppPublisherURL={#MyAppURL} -AppSupportURL={#MyAppURL} -AppUpdatesURL={#MyAppURL} -DefaultDirName={autopf}\{#MyAppName} -DisableProgramGroupPage=yes -; Remove the following line to run in administrative install mode (install for all users.) -PrivilegesRequired=lowest -PrivilegesRequiredOverridesAllowed=dialog -OutputDir=..\build\installer -OutputBaseFilename=Spotube-windows-x86_64-setup -SetupIconFile=..\windows\runner\resources\app_icon.ico -Compression=lzma -SolidCompression=yes -WizardStyle=modern - -[Languages] -Name: "english"; MessagesFile: "compiler:Default.isl" - -[Tasks] -Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked - -[Files] -Source: "..\build\windows\runner\Release\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\bitsdojo_window_windows_plugin.lib"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\flutter_windows.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\hotkey_manager_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\libwinmedia.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\libwinmedia_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\permission_handler_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\spotube.exp"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\spotube.lib"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\url_launcher_windows_plugin.dll"; DestDir: "{app}"; Flags: ignoreversion -Source: "..\build\windows\runner\Release\data\*"; DestDir: "{app}\data"; Flags: ignoreversion recursesubdirs createallsubdirs -; NOTE: Don't use "Flags: ignoreversion" on any shared system files - -[Icons] -Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" -Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon - -[Run] -Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent - diff --git a/windows/packaging/exe/inno_setup.iss b/windows/packaging/exe/inno_setup.iss index 4d0035c9..f995d9e9 100644 --- a/windows/packaging/exe/inno_setup.iss +++ b/windows/packaging/exe/inno_setup.iss @@ -20,7 +20,7 @@ Compression=lzma SolidCompression=yes SetupIconFile={{SETUP_ICON_FILE}} WizardStyle=modern -WizardSmallImageFile=spotube-logo.bmp +WizardSmallImageFile="..\\..\\assets\\spotube-logo.bmp" PrivilegesRequired={{PRIVILEGES_REQUIRED}} ArchitecturesAllowed=x64 ArchitecturesInstallIn64BitMode=x64 From ee8406772c80755da3f430af2286ecd2e276d862 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 18 Sep 2024 10:12:15 +0600 Subject: [PATCH 247/261] chore: add webkit2gtk-4.1 as AUR dependency --- aur-struct/.SRCINFO | 1 + aur-struct/PKGBUILD | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/aur-struct/.SRCINFO b/aur-struct/.SRCINFO index 29eedf74..4c07a045 100644 --- a/aur-struct/.SRCINFO +++ b/aur-struct/.SRCINFO @@ -11,6 +11,7 @@ depends = libsecret depends = jsoncpp depends = libnotify depends = xdg-user-dirs +depends = webkit2gtk-4.1 source = https://github.com/KRTirtho/spotube/releases/download/v3.7.1/spotube-linux-3.7.1-x86_64.tar.xz md5sums = 475b1ae9b08f27743a4d4749391ae3db diff --git a/aur-struct/PKGBUILD b/aur-struct/PKGBUILD index 4663c3ab..d7e1052b 100644 --- a/aur-struct/PKGBUILD +++ b/aur-struct/PKGBUILD @@ -8,7 +8,7 @@ arch=(x86_64) url="https://github.com/KRTirtho/spotube/" license=('BSD-4-Clause') groups=() -depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs') +depends=('mpv' 'libappindicator-gtk3' 'libsecret' 'jsoncpp' 'libnotify' 'xdg-user-dirs' 'webkit2gtk-4.1') makedepends=() checkdepends=() optdepends=() From 142d6884c377646c7ebcdf37f872554927bf0fcd Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 19 Sep 2024 21:25:12 +0600 Subject: [PATCH 248/261] chore: update bug reports template --- .github/ISSUE_TEMPLATE/bug_report.yml | 29 +++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index fed66850..a3756d3a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,8 +7,12 @@ labels: body: - type: checkboxes attributes: - label: Is there an existing issue for this? - description: Make sure to check if this issue is a duplicate. + label: Is there an existing issue for this? (Please read the description) + description: | + PLEASE! Make sure to check if this issue is a duplicate. + Don't waste our time, we are working hard to make Spotube better for you. + + Try with multiple similar keywords, and check the closed issues too. options: - label: I have searched the existing issues required: true @@ -16,23 +20,40 @@ body: attributes: label: Current Behavior description: Write what you are experiencing currently. + placeholder: | + The app isn't working as expected. It crashes when I do this... validations: required: true - type: textarea attributes: label: Expected Behavior description: Write what you expected to happen. + placeholder: | + The app should do this when I do that... validations: required: true - type: textarea attributes: label: Steps to reproduce - description: Steps to reproduce the issue. A not well written description might delay the resolve of it. + description: Steps to reproduce the issue. A not well written description might lead to the delay in fixing the issue. placeholder: | 1. I opened the app 2. I did this 3. And that 4. Then this happened + - type: textarea + attributes: + label: Logs + description: | + If you have any logs, paste them here. Make sure to remove any sensitive information. + You can find the logs in the app's Settings > Developers > Logs page. + value: | +
+ Logs + ``` + + ``` +
validations: required: true - type: input @@ -74,7 +95,7 @@ body: - type: checkboxes attributes: label: Self grab - description: If you are a developer and want to work on this issue yourself, you can check this box and wait for maintainer response. We welcome contributions! + description: If you are a developer and want to work on this issue yourself, you can check this box and wait for maintainer response. Any contributions are welcome! options: - label: I'm ready to work on this issue! required: false From ce9627218de84e43d2bbddaec2b0f35757842e37 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 19 Sep 2024 21:26:48 +0600 Subject: [PATCH 249/261] chore: update bug reports template --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a3756d3a..a9c57836 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -50,6 +50,7 @@ body: value: |
Logs + ``` ``` From 200ee4a6bd669c1da4d8caf0b878bcd5546e3fb4 Mon Sep 17 00:00:00 2001 From: Ryze <48110960+sunryze-git@users.noreply.github.com> Date: Mon, 30 Sep 2024 10:18:35 -0400 Subject: [PATCH 250/261] fix: endless song loading issue and no playback #1925 * chore: update bug reports template * chore: update bug reports template * update youtube_explode_dart dependency version --------- Co-authored-by: Kingkor Roy Tirtho --- .github/ISSUE_TEMPLATE/bug_report.yml | 30 +++++++++++++++++++++++---- pubspec.yaml | 2 +- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index fed66850..a9c57836 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,8 +7,12 @@ labels: body: - type: checkboxes attributes: - label: Is there an existing issue for this? - description: Make sure to check if this issue is a duplicate. + label: Is there an existing issue for this? (Please read the description) + description: | + PLEASE! Make sure to check if this issue is a duplicate. + Don't waste our time, we are working hard to make Spotube better for you. + + Try with multiple similar keywords, and check the closed issues too. options: - label: I have searched the existing issues required: true @@ -16,23 +20,41 @@ body: attributes: label: Current Behavior description: Write what you are experiencing currently. + placeholder: | + The app isn't working as expected. It crashes when I do this... validations: required: true - type: textarea attributes: label: Expected Behavior description: Write what you expected to happen. + placeholder: | + The app should do this when I do that... validations: required: true - type: textarea attributes: label: Steps to reproduce - description: Steps to reproduce the issue. A not well written description might delay the resolve of it. + description: Steps to reproduce the issue. A not well written description might lead to the delay in fixing the issue. placeholder: | 1. I opened the app 2. I did this 3. And that 4. Then this happened + - type: textarea + attributes: + label: Logs + description: | + If you have any logs, paste them here. Make sure to remove any sensitive information. + You can find the logs in the app's Settings > Developers > Logs page. + value: | +
+ Logs + + ``` + + ``` +
validations: required: true - type: input @@ -74,7 +96,7 @@ body: - type: checkboxes attributes: label: Self grab - description: If you are a developer and want to work on this issue yourself, you can check this box and wait for maintainer response. We welcome contributions! + description: If you are a developer and want to work on this issue yourself, you can check this box and wait for maintainer response. Any contributions are welcome! options: - label: I'm ready to work on this issue! required: false diff --git a/pubspec.yaml b/pubspec.yaml index e4face3c..e0c51cc5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -95,7 +95,7 @@ dependencies: version: ^3.0.2 visibility_detector: ^0.4.0+2 window_manager: ^0.3.9 - youtube_explode_dart: ^2.2.1 + youtube_explode_dart: ^2.2.2 simple_icons: ^10.1.3 jiosaavn: ^0.1.0 draggable_scrollbar: From c1f2ddcdbed4dabd3bd861a0b6a230fce710b02a Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 30 Sep 2024 20:51:28 +0600 Subject: [PATCH 251/261] chore: remove donation links from about page to avoid Google's imaginary "pay cut" policy strike --- lib/pages/settings/about.dart | 58 ----------------------------------- 1 file changed, 58 deletions(-) diff --git a/lib/pages/settings/about.dart b/lib/pages/settings/about.dart index 4d093cfe..1357c52f 100644 --- a/lib/pages/settings/about.dart +++ b/lib/pages/settings/about.dart @@ -8,7 +8,6 @@ import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_package_info.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -139,63 +138,6 @@ class AboutSpotube extends HookConsumerWidget { ), ), const SizedBox(height: 20), - Wrap( - runSpacing: 20, - spacing: 20, - alignment: WrapAlignment.center, - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - launchUrl( - Uri.parse("https://www.buymeacoffee.com/krtirtho"), - mode: LaunchMode.externalApplication, - ); - }, - child: SvgPicture.network( - "https://img.buymeacoffee.com/button-api/?text=Buy me a coffee&emoji=&slug=krtirtho&button_colour=FF5F5F&font_colour=ffffff&font_family=Inter&outline_colour=000000&coffee_colour=FFDD00", - height: 45, - ), - ), - ), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - launchUrl( - Uri.parse( - "https://opencollective.com/spotube", - ), - mode: LaunchMode.externalApplication, - ); - }, - child: Image.network( - "https://opencollective.com/spotube/donate/button.png?color=blue", - height: 45, - ), - ), - ), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - launchUrl( - Uri.parse("https://patreon.com/krtirtho"), - mode: LaunchMode.externalApplication, - ); - }, - child: Image.network( - "https://user-images.githubusercontent.com/61944859/180249027-678b01b8-c336-451e-b147-6d84a5b9d0e7.png", - height: 45, - ), - ), - ), - ], - ), - const SizedBox(height: 20), Text( context.l10n.made_with, textAlign: TextAlign.center, From 34d8bc26fe83c025f3c01883994b33f88047d0cf Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 30 Sep 2024 21:53:12 +0600 Subject: [PATCH 252/261] chore: upgrade to Flutter 3.24.3 --- .fvm/fvm_config.json | 3 +- .fvmrc | 4 + .gitignore | 4 +- .vscode/settings.json | 5 +- android/gradle.properties | 2 +- ios/Podfile.lock | 16 +- lib/hooks/configurators/use_deep_linking.dart | 2 +- macos/Podfile.lock | 8 +- macos/Runner/AppDelegate.swift | 2 +- pubspec.lock | 142 +++++++++++------- pubspec.yaml | 14 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 13 files changed, 128 insertions(+), 78 deletions(-) create mode 100644 .fvmrc diff --git a/.fvm/fvm_config.json b/.fvm/fvm_config.json index 160b5b29..305f34df 100644 --- a/.fvm/fvm_config.json +++ b/.fvm/fvm_config.json @@ -1,4 +1,3 @@ { - "flutterSdkVersion": "3.22.3", - "flavors": {} + "flutterSdkVersion": "3.24.3" } \ No newline at end of file diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..c62692b4 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,4 @@ +{ + "flutter": "3.24.3", + "flavors": {} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4f9ebc28..f9bd15f8 100644 --- a/.gitignore +++ b/.gitignore @@ -73,8 +73,10 @@ dist appimage-build android/key.properties -.fvm/flutter_sdk **/pb_data tm.json + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 0ec6ca76..38d74e4e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -25,6 +25,7 @@ "explorer.fileNesting.patterns": { "pubspec.yaml": "pubspec.lock,analysis_options.yaml,.packages,.flutter-plugins,.flutter-plugins-dependencies,flutter_launcher_icons*.yaml,flutter_native_splash*.yaml", "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", - "*.dart": "${capture}.g.dart,${capture}.freezed.dart", - } + "*.dart": "${capture}.g.dart,${capture}.freezed.dart" + }, + "dart.flutterSdkPath": ".fvm/versions/3.24.3" } \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index 94adc3a3..ed508580 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4608m android.useAndroidX=true android.enableJetifier=true diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a59f65eb..2d570cbc 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - app_links (0.0.1): + - app_links (0.0.2): - Flutter - audio_service (0.0.1): - Flutter @@ -54,10 +54,10 @@ PODS: - flutter_inappwebview_ios (0.0.1): - Flutter - flutter_inappwebview_ios/Core (= 0.0.1) - - OrderedSet (~> 5.0) + - OrderedSet (~> 6.0.3) - flutter_inappwebview_ios/Core (0.0.1): - Flutter - - OrderedSet (~> 5.0) + - OrderedSet (~> 6.0.3) - flutter_native_splash (0.0.1): - Flutter - flutter_secure_storage (6.0.0): @@ -74,7 +74,7 @@ PODS: - Flutter - metadata_god (0.0.1): - Flutter - - OrderedSet (5.0.0) + - OrderedSet (6.0.3) - package_info_plus (0.4.5): - Flutter - path_provider_foundation (0.0.1): @@ -202,7 +202,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795 + app_links: e7a6750a915a9e161c58d91bc610e8cd1d4d0ad0 audio_service: f509d65da41b9521a61f1c404dd58651f265a567 audio_session: 088d2483ebd1dc43f51d253d4a1c517d9a2e7207 bonsoir_darwin: e3b8526c42ca46a885142df84229131dfabea842 @@ -214,16 +214,16 @@ SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_broadcasts: 3ece15b27d8ccbe2132c3df303e7c3401feab882 flutter_discord_rpc: e1c342f29ceb9dd76cdc01db59a70c93bb4d9ec5 - flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 + flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4 flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be flutter_sharing_intent: e35380d0e1501d7111dbb7e46d5ac6339da6da98 image_picker_ios: b545a5f16c0fa88e3ecbbce3ed4de45567a8ec18 - integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4 + integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 media_kit_libs_ios_audio: 8f39d96a9c630685dfb844c289bd1d114c486fb3 media_kit_native_event_loop: 99111eded5acbdc9c2738021ea6550dd36ca8837 metadata_god: 4bbd8523cdb5d42c5e59d2fabad01ff8f4bc53f9 - OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c + OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 diff --git a/lib/hooks/configurators/use_deep_linking.dart b/lib/hooks/configurators/use_deep_linking.dart index 0bb27a11..ec6d8516 100644 --- a/lib/hooks/configurators/use_deep_linking.dart +++ b/lib/hooks/configurators/use_deep_linking.dart @@ -11,7 +11,7 @@ import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/utils/platform.dart'; final appLinks = AppLinks(); -final linkStream = appLinks.allStringLinkStream.asBroadcastStream(); +final linkStream = appLinks.stringLinkStream.asBroadcastStream(); void useDeepLinking(WidgetRef ref) { // single instance no worries diff --git a/macos/Podfile.lock b/macos/Podfile.lock index b3092d8c..acc50c99 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -18,7 +18,7 @@ PODS: - FlutterMacOS - flutter_inappwebview_macos (0.0.1): - FlutterMacOS - - OrderedSet (~> 5.0) + - OrderedSet (~> 6.0.3) - flutter_secure_storage_macos (6.1.1): - FlutterMacOS - FlutterMacOS (1.0.0) @@ -30,7 +30,7 @@ PODS: - FlutterMacOS - metadata_god (0.0.1): - FlutterMacOS - - OrderedSet (5.0.0) + - OrderedSet (6.0.3) - package_info_plus (0.0.1): - FlutterMacOS - path_provider_foundation (0.0.1): @@ -161,14 +161,14 @@ SPEC CHECKSUMS: device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720 file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 flutter_discord_rpc: 67a7c10ea24d9d3bf35d01af643f48fbcfa7c24f - flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d + flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 local_notifier: e9506bc66fc70311e8bc7291fb70f743c081e4ff media_kit_libs_macos_audio: 3871782a4f3f84c77f04d7666c87800a781c24da media_kit_native_event_loop: 7321675377cb9ae8596a29bddf3a3d2b5e8792c5 metadata_god: 829f61208b44ac1173e7cd32ab740d8776be5435 - OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c + OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94 package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index 218f93e0..a6f73a80 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false diff --git a/pubspec.lock b/pubspec.lock index 089563d8..553f0dc9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -37,10 +37,34 @@ packages: dependency: "direct main" description: name: app_links - sha256: "42dc15aecf2618ace4ffb74a2e58a50e45cd1b9f2c17c8f0cafe4c297f08c815" + sha256: ad1a6d598e7e39b46a34f746f9a8b011ee147e4c275d407fa457e7a62f84dd99 url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "6.3.2" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" archive: dependency: transitive description: @@ -205,10 +229,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: "0343061a33da9c5810b2d6cee51945127d8f4c060b7fbdd9d54917f0a3feaaa1" + sha256: "79b2aef6ac2ed00046867ed354c88778c9c0f029df8a20fe10b5436826721ef9" url: "https://pub.dev" source: hosted - version: "4.0.1" + version: "4.0.2" build_resolvers: dependency: transitive description: @@ -221,10 +245,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "3ac61a79bfb6f6cc11f693591063a7f19a7af628dc52f141743edac5c16e8c22" + sha256: dd09dd4e2b078992f42aac7f1a622f01882a8492fef08486b27ddde929c19f04 url: "https://pub.dev" source: hosted - version: "2.4.9" + version: "2.4.12" build_runner_core: dependency: transitive description: @@ -583,10 +607,10 @@ packages: dependency: transitive description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" file: dependency: transitive description: @@ -762,18 +786,18 @@ packages: dependency: "direct main" description: name: flutter_inappwebview - sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" + sha256: "274edbb07196944e316722d9f6f641c77d0e71261200869887e10f59614c0458" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.3" flutter_inappwebview_android: dependency: transitive description: name: flutter_inappwebview_android - sha256: d247f6ed417f1f8c364612fa05a2ecba7f775c8d0c044c1d3b9ee33a6515c421 + sha256: f48203a11c5eb0c23dd5a3cb3638ae678056b6ceae22819373e36c6cb4f1d46a url: "https://pub.dev" source: hosted - version: "1.0.13" + version: "1.1.1" flutter_inappwebview_internal_annotations: dependency: transitive description: @@ -786,34 +810,42 @@ packages: dependency: transitive description: name: flutter_inappwebview_ios - sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f + sha256: f6f88d464b38f2fc1c5f2ae74024498115eb1470715bd8b40f902dd4ac99ccc8 url: "https://pub.dev" source: hosted - version: "1.0.13" + version: "1.1.1" flutter_inappwebview_macos: dependency: transitive description: name: flutter_inappwebview_macos - sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636 + sha256: "68e0c3785d8d789710cda7d7efe6effa337c91bf300dd28af7efc2d358fa1a98" url: "https://pub.dev" source: hosted - version: "1.0.11" + version: "1.1.1" flutter_inappwebview_platform_interface: dependency: transitive description: name: flutter_inappwebview_platform_interface - sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187" + sha256: "97b4ab116d949ede20c90c7e3d15d24afaf1b706cc0af96b060770293cd6c49d" url: "https://pub.dev" source: hosted - version: "1.0.10" + version: "1.2.0" flutter_inappwebview_web: dependency: transitive description: name: flutter_inappwebview_web - sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07 + sha256: f7f97b6faa39416e4e86da1184edd4de6c27b271d036f0838ea3ff9a250a1de2 url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.1.1" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "86702d2109384311f8ea634855e90ee143b9bfabddd3858696d905a2c28808aa" + url: "https://pub.dev" + source: hosted + version: "0.4.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -1259,18 +1291,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" url: "https://pub.dev" source: hosted - version: "10.0.4" + version: "10.0.5" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.5" leak_tracker_testing: dependency: transitive description: @@ -1331,10 +1363,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.8.0" + version: "0.11.1" media_kit: dependency: "direct main" description: @@ -1411,10 +1443,10 @@ packages: dependency: transitive description: name: meta - sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 url: "https://pub.dev" source: hosted - version: "1.12.0" + version: "1.15.0" metadata_god: dependency: "direct main" description: @@ -1620,10 +1652,10 @@ packages: dependency: transitive description: name: platform - sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "3.1.5" plugin_platform_interface: dependency: transitive description: @@ -1861,10 +1893,10 @@ packages: dependency: "direct main" description: name: shelf_web_socket - sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "2.0.0" shortid: dependency: transitive description: @@ -2091,10 +2123,10 @@ packages: dependency: transitive description: name: test_api - sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" url: "https://pub.dev" source: hosted - version: "0.7.0" + version: "0.7.2" time: dependency: transitive description: @@ -2251,10 +2283,10 @@ packages: dependency: "direct main" description: name: uuid - sha256: "814e9e88f21a176ae1359149021870e87f7cddaf633ab678a5d2b0bff7fd1ba8" + sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.5.1" vector_math: dependency: transitive description: @@ -2291,10 +2323,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" url: "https://pub.dev" source: hosted - version: "14.2.1" + version: "14.2.5" watcher: dependency: transitive description: @@ -2304,21 +2336,29 @@ packages: source: hosted version: "1.1.0" web: - dependency: transitive + dependency: "direct overridden" description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" web_socket_channel: dependency: "direct main" description: name: web_socket_channel - sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "3.0.1" webdriver: dependency: transitive description: @@ -2339,18 +2379,18 @@ packages: dependency: transitive description: name: win32 - sha256: "0a989dc7ca2bb51eac91e8fd00851297cfffd641aa7538b165c62637ca0eaa4a" + sha256: "68d1e89a91ed61ad9c370f9f8b6effed9ae5e0ede22a270bdfa6daf79fc2290a" url: "https://pub.dev" source: hosted - version: "5.4.0" + version: "5.5.4" win32_registry: dependency: "direct main" description: name: win32_registry - sha256: "10589e0d7f4e053f2c61023a31c9ce01146656a70b7b7f0828c0b46d7da2a9bb" + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.1.5" window_manager: dependency: "direct main" description: @@ -2387,10 +2427,10 @@ packages: dependency: "direct main" description: name: youtube_explode_dart - sha256: "26c9671d638f3396a1bfb2666f586988ee7b0ba3469e478b22a4c1a168bcf6ee" + sha256: "133a65907e6cf839ac7643d92dc5c56b37fcebe4f0a8f0e67716dffa500c0ef0" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" sdks: - dart: ">=3.4.0 <4.0.0" - flutter: ">=3.19.2" + dart: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index e0c51cc5..6f5019a2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,7 @@ dependencies: auto_size_text: ^3.0.0 buttons_tabbar: ^1.3.8 cached_network_image: ^3.3.1 - collection: ^1.15.0 + collection: ^1.18.0 curved_navigation_bar: ^1.0.3 desktop_webview_window: git: @@ -42,7 +42,7 @@ dependencies: flutter_displaymode: ^0.6.0 flutter_feather_icons: ^2.0.0+1 flutter_hooks: ^0.20.5 - flutter_inappwebview: ^6.0.0 + flutter_inappwebview: ^6.1.3 flutter_localizations: sdk: flutter flutter_native_splash: ^2.4.0 @@ -113,8 +113,8 @@ dependencies: html_unescape: ^2.0.0 wikipedia_api: ^0.1.0 skeletonizer: ^1.1.1 - app_links: ^4.0.1 - win32_registry: ^1.1.3 + app_links: ^6.3.2 + win32_registry: ^1.1.5 flutter_sharing_intent: ^1.1.0 flutter_broadcasts: ^0.4.0 freezed_annotation: ^2.4.1 @@ -122,8 +122,8 @@ dependencies: bonsoir: ^5.1.10 shelf: ^1.4.1 shelf_router: ^1.1.4 - shelf_web_socket: ^1.0.4 - web_socket_channel: ^2.4.5 + shelf_web_socket: ^2.0.0 + web_socket_channel: ^3.0.1 lrc: ^1.0.2 timezone: ^0.9.2 local_notifier: ^0.1.6 @@ -160,7 +160,7 @@ dev_dependencies: drift_dev: ^2.18.0 dependency_overrides: - uuid: ^4.4.0 + web: ^1.1.0 flutter: generate: true diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index f2d60e21..c10169b1 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); LocalNotifierPluginRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index bea4d801..7cb17288 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST bonsoir_windows desktop_webview_window file_selector_windows + flutter_inappwebview_windows flutter_secure_storage_windows local_notifier media_kit_libs_windows_audio From b87a51011b199017db0f8d3c7207c25dba0b4130 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 30 Sep 2024 21:56:34 +0600 Subject: [PATCH 253/261] chore: bump to v3.8.2 abd generate changelogs --- .github/workflows/spotube-publish-binary.yml | 2 +- CHANGELOG.md | 9 +++++++++ pubspec.yaml | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index a5ee3449..3153c279 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.8.1 + default: 3.8.2 required: true dry_run: description: Dry run diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b48c26..8297a7c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ 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.8.2](https://github.com/krtirtho/spotube/compare/v3.8.0...v3.8.1) (2024-09-30) + +## Changes + +### Bug Fixes + +- endless song loading issue and no playback #1925 + + ## [3.8.1](https://github.com/krtirtho/spotube/compare/v3.8.0...v3.8.1) (2024-09-15) ## Changes diff --git a/pubspec.yaml b/pubspec.yaml index e0c51cc5..85b7a719 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.8.1+34 +version: 3.8.2+35 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube From 58f6142d2fbce2980449c581d463dba718bdd718 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 9 Oct 2024 11:38:48 +0600 Subject: [PATCH 254/261] chore: add fvm local flutter symlink in vscode settings --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 38d74e4e..11fae610 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -27,5 +27,5 @@ "README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md", "*.dart": "${capture}.g.dart,${capture}.freezed.dart" }, - "dart.flutterSdkPath": ".fvm/versions/3.24.3" + "dart.flutterSdkPath": ".fvm/flutter_sdk" } \ No newline at end of file From 4e0de13075b804e432c9d48ba06ecdb98d5f9cd8 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 9 Oct 2024 11:55:32 +0600 Subject: [PATCH 255/261] cd: upgrade flutter version --- .github/workflows/spotube-release-binary.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/spotube-release-binary.yml b/.github/workflows/spotube-release-binary.yml index 19fbac82..d059a067 100644 --- a/.github/workflows/spotube-release-binary.yml +++ b/.github/workflows/spotube-release-binary.yml @@ -20,7 +20,7 @@ on: description: Dry run without uploading to release env: - FLUTTER_VERSION: 3.22.3 + FLUTTER_VERSION: 3.24.3 permissions: contents: write From 70bbb4af5abb4043cef82c4364269eee1d392f58 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 9 Oct 2024 14:45:16 +0600 Subject: [PATCH 256/261] feat(macos): enable same window webview support --- .../mobile_login/hooks/login_callback.dart | 2 +- lib/pages/mobile_login/mobile_login.dart | 78 ++++++++++--------- 2 files changed, 42 insertions(+), 38 deletions(-) diff --git a/lib/pages/mobile_login/hooks/login_callback.dart b/lib/pages/mobile_login/hooks/login_callback.dart index 1648da19..07c0210a 100644 --- a/lib/pages/mobile_login/hooks/login_callback.dart +++ b/lib/pages/mobile_login/hooks/login_callback.dart @@ -19,7 +19,7 @@ Future Function() useLoginCallback(WidgetRef ref) { final authNotifier = ref.read(authenticationProvider.notifier); return useCallback(() async { - if (kIsMobile) { + if (kIsMobile || kIsMacOS) { context.pushNamed(WebViewLogin.name); return; } diff --git a/lib/pages/mobile_login/mobile_login.dart b/lib/pages/mobile_login/mobile_login.dart index 10a989cf..c45c2184 100644 --- a/lib/pages/mobile_login/mobile_login.dart +++ b/lib/pages/mobile_login/mobile_login.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/provider/authentication/authentication.dart'; import 'package:spotube/utils/platform.dart'; @@ -23,44 +24,47 @@ class WebViewLogin extends HookConsumerWidget { } return Scaffold( - body: SafeArea( - child: InAppWebView( - initialSettings: InAppWebViewSettings( - userAgent: - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 safari/537.36", - ), - initialUrlRequest: URLRequest( - url: WebUri("https://accounts.spotify.com/"), - ), - onPermissionRequest: (controller, permissionRequest) async { - return PermissionResponse( - resources: permissionRequest.resources, - action: PermissionResponseAction.GRANT, - ); - }, - onLoadStop: (controller, action) async { - if (action == null) return; - String url = action.toString(); - if (url.endsWith("/")) { - url = url.substring(0, url.length - 1); - } - - final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); - - if (exp.hasMatch(url)) { - final cookies = - await CookieManager.instance().getCookies(url: action); - final cookieHeader = - "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; - - await authenticationNotifier.login(cookieHeader); - if (context.mounted) { - // ignore: use_build_context_synchronously - GoRouter.of(context).go("/"); - } - } - }, + appBar: const PageWindowTitleBar( + leading: BackButton(color: Colors.white), + backgroundColor: Colors.transparent, + ), + extendBodyBehindAppBar: true, + body: InAppWebView( + initialSettings: InAppWebViewSettings( + userAgent: + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/105.0.0.0 safari/537.36", ), + initialUrlRequest: URLRequest( + url: WebUri("https://accounts.spotify.com/"), + ), + onPermissionRequest: (controller, permissionRequest) async { + return PermissionResponse( + resources: permissionRequest.resources, + action: PermissionResponseAction.GRANT, + ); + }, + onLoadStop: (controller, action) async { + if (action == null) return; + String url = action.toString(); + if (url.endsWith("/")) { + url = url.substring(0, url.length - 1); + } + + final exp = RegExp(r"https:\/\/accounts.spotify.com\/.+\/status"); + + if (exp.hasMatch(url)) { + final cookies = + await CookieManager.instance().getCookies(url: action); + final cookieHeader = + "sp_dc=${cookies.firstWhere((element) => element.name == "sp_dc").value}"; + + await authenticationNotifier.login(cookieHeader); + if (context.mounted) { + // ignore: use_build_context_synchronously + GoRouter.of(context).go("/"); + } + } + }, ), ); } From a76b5c4c7fc7479b4d27c4b7c100cf3f3e9b0f08 Mon Sep 17 00:00:00 2001 From: Xavi Fortes <53091080+XaviFortes@users.noreply.github.com> Date: Wed, 9 Oct 2024 10:47:11 +0200 Subject: [PATCH 257/261] fix: update youtube_explode_dart to 2.2.3 to fix no playback (#1980) * chore: update bug reports template * chore: update bug reports template * Update youtube_explode_dart to 2.2.3 fixes 403 --------- Co-authored-by: Kingkor Roy Tirtho --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 3b1bd826..6560a450 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -95,7 +95,7 @@ dependencies: version: ^3.0.2 visibility_detector: ^0.4.0+2 window_manager: ^0.3.9 - youtube_explode_dart: ^2.2.2 + youtube_explode_dart: ^2.2.3 simple_icons: ^10.1.3 jiosaavn: ^0.1.0 draggable_scrollbar: From fdde972a775897b131199b9512a32c76c2946787 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 9 Oct 2024 14:51:24 +0600 Subject: [PATCH 258/261] chore: bump version to 3.8.3 and generate changelogs --- .github/workflows/spotube-publish-binary.yml | 2 +- CHANGELOG.md | 14 +++++++++++++- pubspec.lock | 4 ++-- pubspec.yaml | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/spotube-publish-binary.yml b/.github/workflows/spotube-publish-binary.yml index 3153c279..3a456bda 100644 --- a/.github/workflows/spotube-publish-binary.yml +++ b/.github/workflows/spotube-publish-binary.yml @@ -4,7 +4,7 @@ on: inputs: version: description: Version to publish (x.x.x) - default: 3.8.2 + default: 3.8.3 required: true dry_run: description: Dry run diff --git a/CHANGELOG.md b/CHANGELOG.md index 8297a7c4..11b06ed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,19 @@ 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.8.2](https://github.com/krtirtho/spotube/compare/v3.8.0...v3.8.1) (2024-09-30) +## [3.8.3](https://github.com/krtirtho/spotube/compare/v3.8.2...v3.8.3) (2024-10-09) + +## Changes + +### Bug Fixes + +- update youtube_explode_dart to 2.2.3 to fix no playback (#1980) + +### Features + +- **macos**: enable same window webview support + +## [3.8.2](https://github.com/krtirtho/spotube/compare/v3.8.1...v3.8.2) (2024-09-30) ## Changes diff --git a/pubspec.lock b/pubspec.lock index 553f0dc9..77193ca0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2427,10 +2427,10 @@ packages: dependency: "direct main" description: name: youtube_explode_dart - sha256: "133a65907e6cf839ac7643d92dc5c56b37fcebe4f0a8f0e67716dffa500c0ef0" + sha256: "28dca07fefb4b6518beab95f0c1ef81031f921ed0fe87ebcd9c51378546edfee" url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" sdks: dart: ">=3.5.0 <4.0.0" flutter: ">=3.24.0" diff --git a/pubspec.yaml b/pubspec.yaml index 6560a450..571f6011 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.8.2+35 +version: 3.8.3+36 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube From f553e43b1734c6c455b838884f542684b4925b76 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 9 Oct 2024 16:37:41 +0600 Subject: [PATCH 259/261] chore: hide donations link for playstore version to adhere with Google Play's iae policy This commit keeps food on one google PM's table by not stealing their 30% pay cut --- .env.example | 2 + cli/commands/build/android.dart | 3 +- lib/collections/assets.gen.dart | 3 + lib/collections/env.dart | 7 +- .../getting_started/sections/support.dart | 31 ++++---- lib/pages/settings/sections/about.dart | 76 ++++++++++--------- 6 files changed, 69 insertions(+), 53 deletions(-) diff --git a/.env.example b/.env.example index 56665663..6a88cb99 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,5 @@ LASTFM_API_SECRET= # Release channel. Can be: nightly, stable RELEASE_CHANNEL= + +HIDE_DONATIONS= diff --git a/cli/commands/build/android.dart b/cli/commands/build/android.dart index 800522b8..fe2db2e2 100644 --- a/cli/commands/build/android.dart +++ b/cli/commands/build/android.dart @@ -25,7 +25,8 @@ class AndroidBuildCommand extends Command with BuildCommandCommonSteps { ); await dotEnvFile.writeAsString( - "\nENABLE_UPDATE_CHECK=0", + "\nENABLE_UPDATE_CHECK=0" + "\nHIDE_DONATIONS=1", mode: FileMode.append, ); diff --git a/lib/collections/assets.gen.dart b/lib/collections/assets.gen.dart index 2a30260b..cff5b74f 100644 --- a/lib/collections/assets.gen.dart +++ b/lib/collections/assets.gen.dart @@ -59,6 +59,8 @@ class Assets { AssetGenImage('assets/spotube-hero-banner.png'); static const AssetGenImage spotubeLogoForeground = AssetGenImage('assets/spotube-logo-foreground.jpg'); + static const AssetGenImage spotubeLogoBmp = + AssetGenImage('assets/spotube-logo.bmp'); static const String spotubeLogoIco = 'assets/spotube-logo.ico'; static const AssetGenImage spotubeLogoPng = AssetGenImage('assets/spotube-logo.png'); @@ -98,6 +100,7 @@ class Assets { placeholder, spotubeHeroBanner, spotubeLogoForeground, + spotubeLogoBmp, spotubeLogoIco, spotubeLogoPng, spotubeLogoSvg, diff --git a/lib/collections/env.dart b/lib/collections/env.dart index df45cee9..eb60851f 100644 --- a/lib/collections/env.dart +++ b/lib/collections/env.dart @@ -19,6 +19,11 @@ abstract class Env { @EnviedField(varName: 'LASTFM_API_SECRET') static final String lastFmApiSecret = _Env.lastFmApiSecret; + @EnviedField(varName: 'HIDE_DONATIONS', defaultValue: "0") + static final int _hideDonations = _Env._hideDonations; + + static bool get hideDonations => _hideDonations == 1; + static final spotifySecrets = rawSpotifySecrets.split(',').map((e) { final secrets = e.trim().split(":").map((e) => e.trim()); return { @@ -41,4 +46,4 @@ abstract class Env { kIsFlatpak || _enableUpdateChecker == "1"; static String discordAppId = "1176718791388975124"; -} \ No newline at end of file +} diff --git a/lib/pages/getting_started/sections/support.dart b/lib/pages/getting_started/sections/support.dart index 3f669557..f09a585d 100644 --- a/lib/pages/getting_started/sections/support.dart +++ b/lib/pages/getting_started/sections/support.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/modules/getting_started/blur_card.dart'; import 'package:spotube/extensions/context.dart'; @@ -62,21 +63,23 @@ class GettingStartedScreenSupportSection extends HookConsumerWidget { ); }, ), - const Gap(16), - FilledButton.icon( - icon: const Icon(SpotubeIcons.openCollective), - label: Text(context.l10n.donate_on_open_collective), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xff4cb7f6), - foregroundColor: Colors.white, + if (!Env.hideDonations) ...[ + const Gap(16), + FilledButton.icon( + icon: const Icon(SpotubeIcons.openCollective), + label: Text(context.l10n.donate_on_open_collective), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xff4cb7f6), + foregroundColor: Colors.white, + ), + onPressed: () async { + await launchUrlString( + "https://opencollective.com/spotube", + mode: LaunchMode.externalApplication, + ); + }, ), - onPressed: () async { - await launchUrlString( - "https://opencollective.com/spotube", - mode: LaunchMode.externalApplication, - ); - }, - ), + ] ], ), ], diff --git a/lib/pages/settings/sections/about.dart b/lib/pages/settings/sections/about.dart index dfb5272b..a0a5bf30 100644 --- a/lib/pages/settings/sections/about.dart +++ b/lib/pages/settings/sections/about.dart @@ -21,48 +21,50 @@ class SettingsAboutSection extends HookConsumerWidget { return SectionCardWithHeading( heading: context.l10n.about, children: [ - AdaptiveListTile( - leading: const Icon( - SpotubeIcons.heart, - color: Colors.pink, - ), - title: SizedBox( - height: 50, - width: 200, - child: Align( - alignment: Alignment.centerLeft, - child: AutoSizeText( - context.l10n.u_love_spotube, - maxLines: 1, - style: const TextStyle( - color: Colors.pink, - fontWeight: FontWeight.bold, + if (!Env.hideDonations) + AdaptiveListTile( + leading: const Icon( + SpotubeIcons.heart, + color: Colors.pink, + ), + title: SizedBox( + height: 50, + width: 200, + child: Align( + alignment: Alignment.centerLeft, + child: AutoSizeText( + context.l10n.u_love_spotube, + maxLines: 1, + style: const TextStyle( + color: Colors.pink, + fontWeight: FontWeight.bold, + ), ), ), ), - ), - trailing: (context, update) => FilledButton( - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll(Colors.red[100]), - foregroundColor: const WidgetStatePropertyAll(Colors.pinkAccent), - padding: const WidgetStatePropertyAll(EdgeInsets.all(15)), - ), - onPressed: () { - launchUrlString( - "https://opencollective.com/spotube", - mode: LaunchMode.externalApplication, - ); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(SpotubeIcons.heart), - const SizedBox(width: 5), - Text(context.l10n.please_sponsor), - ], + trailing: (context, update) => FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStatePropertyAll(Colors.red[100]), + foregroundColor: + const WidgetStatePropertyAll(Colors.pinkAccent), + padding: const WidgetStatePropertyAll(EdgeInsets.all(15)), + ), + onPressed: () { + launchUrlString( + "https://opencollective.com/spotube", + mode: LaunchMode.externalApplication, + ); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(SpotubeIcons.heart), + const SizedBox(width: 5), + Text(context.l10n.please_sponsor), + ], + ), ), ), - ), if (Env.enableUpdateChecker) SwitchListTile( secondary: const Icon(SpotubeIcons.update), From 9cd839cfd920176fc7ac2405896f10072314c19c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Wed, 9 Oct 2024 22:08:02 +0600 Subject: [PATCH 260/261] cd: fix env not getting updated --- cli/commands/build/android.dart | 1 + pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cli/commands/build/android.dart b/cli/commands/build/android.dart index fe2db2e2..4216553a 100644 --- a/cli/commands/build/android.dart +++ b/cli/commands/build/android.dart @@ -51,6 +51,7 @@ class AndroidBuildCommand extends Command with BuildCommandCommonSteps { await shell.run( """ + dart run build_runner clean dart run build_runner build --delete-conflicting-outputs flutter build appbundle --flavor ${CliEnv.channel.name} """, diff --git a/pubspec.yaml b/pubspec.yaml index 571f6011..df8e668d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: Open source Spotify client that doesn't require Premium nor uses El publish_to: "none" -version: 3.8.3+36 +version: 3.8.3+37 homepage: https://spotube.krtirtho.dev repository: https://github.com/KRTirtho/spotube From 6e1cd96903702724b8d7e39926de38992f906bdc Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sun, 13 Oct 2024 10:44:05 +0600 Subject: [PATCH 261/261] website: fix older version download not working #1985 --- website/README.md | 14 +- website/package-lock.json | 6391 ----------------- website/package.json | 26 +- website/pnpm-lock.yaml | 4153 +++++++++++ .../src/routes/downloads/older/+page.svelte | 2 +- website/src/routes/downloads/older/+page.ts | 10 +- 6 files changed, 4179 insertions(+), 6417 deletions(-) delete mode 100644 website/package-lock.json create mode 100644 website/pnpm-lock.yaml diff --git a/website/README.md b/website/README.md index 5ce67661..ad252bd7 100644 --- a/website/README.md +++ b/website/README.md @@ -8,21 +8,21 @@ If you're seeing this, you've probably already done this step. Congrats! ```bash # create a new project in the current directory -npm create svelte@latest +pnpm create svelte@latest # create a new project in my-app -npm create svelte@latest my-app +pnpm create svelte@latest my-app ``` ## Developing -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +Once you've created a project and installed dependencies with `pnpm install` (or `pnpm install` or `yarn`), start a development server: ```bash -npm run dev +pnpm run dev # or start the server and open the app in a new browser tab -npm run dev -- --open +pnpm run dev -- --open ``` ## Building @@ -30,9 +30,9 @@ npm run dev -- --open To create a production version of your app: ```bash -npm run build +pnpm run build ``` -You can preview the production build with `npm run preview`. +You can preview the production build with `pnpm run preview`. > To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. diff --git a/website/package-lock.json b/website/package-lock.json deleted file mode 100644 index 89323983..00000000 --- a/website/package-lock.json +++ /dev/null @@ -1,6391 +0,0 @@ -{ - "name": "website", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "website", - "version": "0.0.1", - "dependencies": { - "@floating-ui/dom": "1.6.1", - "@fortawesome/free-brands-svg-icons": "^6.5.1", - "@octokit/openapi-types": "^19.1.0", - "@octokit/rest": "^20.0.2", - "date-fns": "^3.3.1", - "highlight.js": "11.9.0", - "lucide-svelte": "^0.323.0", - "mdsvex-relative-images": "^1.0.3", - "rehype-autolink-headings": "^7.1.0", - "rehype-slug": "^6.0.0", - "remark-container": "^0.1.2", - "remark-external-links": "^9.0.1", - "remark-gfm": "^4.0.0", - "remark-github": "^12.0.0", - "remark-reading-time": "^1.0.1", - "svelte-fa": "^4.0.2", - "svelte-markdown": "^0.4.1" - }, - "devDependencies": { - "@playwright/test": "^1.28.1", - "@skeletonlabs/skeleton": "2.8.0", - "@skeletonlabs/tw-plugin": "0.3.1", - "@sveltejs/adapter-cloudflare": "^4.1.0", - "@sveltejs/kit": "^2.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0", - "@tailwindcss/typography": "0.5.10", - "@types/eslint": "8.56.0", - "@types/node": "^20.11.16", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", - "autoprefixer": "10.4.17", - "eslint": "^8.56.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-svelte": "^2.35.1", - "mdsvex": "^0.11.0", - "postcss": "8.4.35", - "prettier": "^3.1.1", - "prettier-plugin-svelte": "^3.1.2", - "svelte": "^4.2.7", - "svelte-check": "^3.6.0", - "tailwindcss": "3.4.1", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^5.0.3", - "vite-plugin-tailwind-purgecss": "0.2.0" - } - }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@cloudflare/workers-types": { - "version": "4.20240208.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20240208.0.tgz", - "integrity": "sha512-MVGTTjZpJu4kJONvai5SdJzWIhOJbuweVZ3goI7FNyG+JdoQH41OoB+nMhLsX626vPLZVWGPIWsiSo/WZHzgQw==", - "dev": true - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", - "dev": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", - "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@floating-ui/core": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", - "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", - "dependencies": { - "@floating-ui/utils": "^0.2.1" - } - }, - "node_modules/@floating-ui/dom": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.1.tgz", - "integrity": "sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==", - "dependencies": { - "@floating-ui/core": "^1.6.0", - "@floating-ui/utils": "^0.2.1" - } - }, - "node_modules/@floating-ui/utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", - "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" - }, - "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", - "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==", - "hasInstallScript": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.1.tgz", - "integrity": "sha512-093l7DAkx0aEtBq66Sf19MgoZewv1zeY9/4C7vSKPO4qMwEsW/2VYTUTpBtLwfb9T2R73tXaRDPmE4UqLCYHfg==", - "hasInstallScript": true, - "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dev": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@octokit/auth-token": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", - "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.1.0.tgz", - "integrity": "sha512-BDa2VAMLSh3otEiaMJ/3Y36GU4qf6GI+VivQ/P41NC6GHcdxpKlqV0ikSZ5gdQsmS3ojXeRx5vasgNTinF0Q4g==", - "dependencies": { - "@octokit/auth-token": "^4.0.0", - "@octokit/graphql": "^7.0.0", - "@octokit/request": "^8.0.2", - "@octokit/request-error": "^5.0.0", - "@octokit/types": "^12.0.0", - "before-after-hook": "^2.2.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/endpoint": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.4.tgz", - "integrity": "sha512-DWPLtr1Kz3tv8L0UvXTDP1fNwM0S+z6EJpRcvH66orY6Eld4XBMCSYsaWp4xIm61jTWxK68BrR7ibO+vSDnZqw==", - "dependencies": { - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/graphql": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.0.2.tgz", - "integrity": "sha512-OJ2iGMtj5Tg3s6RaXH22cJcxXRi7Y3EBqbHTBRq+PQAqfaS8f/236fUrWhfSn8P4jovyzqucxme7/vWSSZBX2Q==", - "dependencies": { - "@octokit/request": "^8.0.1", - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/openapi-types": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-19.1.0.tgz", - "integrity": "sha512-6G+ywGClliGQwRsjvqVYpklIfa7oRPA0vyhPQG/1Feh+B+wU0vGH1JiJ5T25d3g1JZYBHzR2qefLi9x8Gt+cpw==" - }, - "node_modules/@octokit/plugin-paginate-rest": { - "version": "9.1.5", - "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-9.1.5.tgz", - "integrity": "sha512-WKTQXxK+bu49qzwv4qKbMMRXej1DU2gq017euWyKVudA6MldaSSQuxtz+vGbhxV4CjxpUxjZu6rM2wfc1FiWVg==", - "dependencies": { - "@octokit/types": "^12.4.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=5" - } - }, - "node_modules/@octokit/plugin-request-log": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-4.0.0.tgz", - "integrity": "sha512-2uJI1COtYCq8Z4yNSnM231TgH50bRkheQ9+aH8TnZanB6QilOnx8RMD2qsnamSOXtDj0ilxvevf5fGsBhBBzKA==", - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=5" - } - }, - "node_modules/@octokit/plugin-rest-endpoint-methods": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-10.2.0.tgz", - "integrity": "sha512-ePbgBMYtGoRNXDyKGvr9cyHjQ163PbwD0y1MkDJCpkO2YH4OeXX40c4wYHKikHGZcpGPbcRLuy0unPUuafco8Q==", - "dependencies": { - "@octokit/types": "^12.3.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "@octokit/core": ">=5" - } - }, - "node_modules/@octokit/request": { - "version": "8.1.6", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.1.6.tgz", - "integrity": "sha512-YhPaGml3ncZC1NfXpP3WZ7iliL1ap6tLkAp6MvbK2fTTPytzVUyUesBBogcdMm86uRYO5rHaM1xIWxigWZ17MQ==", - "dependencies": { - "@octokit/endpoint": "^9.0.0", - "@octokit/request-error": "^5.0.0", - "@octokit/types": "^12.0.0", - "universal-user-agent": "^6.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/request-error": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.0.1.tgz", - "integrity": "sha512-X7pnyTMV7MgtGmiXBwmO6M5kIPrntOXdyKZLigNfQWSEQzVxR4a4vo49vJjTWX70mPndj8KhfT4Dx+2Ng3vnBQ==", - "dependencies": { - "@octokit/types": "^12.0.0", - "deprecation": "^2.0.0", - "once": "^1.4.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/rest": { - "version": "20.0.2", - "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.0.2.tgz", - "integrity": "sha512-Ux8NDgEraQ/DMAU1PlAohyfBBXDwhnX2j33Z1nJNziqAfHi70PuxkFYIcIt8aIAxtRE7KVuKp8lSR8pA0J5iOQ==", - "dependencies": { - "@octokit/core": "^5.0.0", - "@octokit/plugin-paginate-rest": "^9.0.0", - "@octokit/plugin-request-log": "^4.0.0", - "@octokit/plugin-rest-endpoint-methods": "^10.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/types": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-12.4.0.tgz", - "integrity": "sha512-FLWs/AvZllw/AGVs+nJ+ELCDZZJk+kY0zMen118xhL2zD0s1etIUHm1odgjP7epxYU1ln7SZxEUWYop5bhsdgQ==", - "dependencies": { - "@octokit/openapi-types": "^19.1.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@playwright/test": { - "version": "1.41.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz", - "integrity": "sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==", - "dev": true, - "dependencies": { - "playwright": "1.41.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.24", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", - "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", - "dev": true - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz", - "integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz", - "integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz", - "integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz", - "integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz", - "integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz", - "integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz", - "integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz", - "integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz", - "integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz", - "integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz", - "integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz", - "integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz", - "integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@skeletonlabs/skeleton": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@skeletonlabs/skeleton/-/skeleton-2.8.0.tgz", - "integrity": "sha512-R6spSJSyW9MA6cnVQ8IV7uoYSXxHmP/oWJ9IHdGDU9epPZaZMmOXUHJSzA1gngccB2jFaA/6jXfS1O1CsIlGMg==", - "dev": true, - "dependencies": { - "esm-env": "1.0.0" - }, - "peerDependencies": { - "svelte": "^3.56.0 || ^4.0.0" - } - }, - "node_modules/@skeletonlabs/tw-plugin": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@skeletonlabs/tw-plugin/-/tw-plugin-0.3.1.tgz", - "integrity": "sha512-DjjeOHN3HhFQf6gYPT2MUZMkIdw1jeB9mbuKC8etQxUlOR4XitfC7hssRWFJ8RJsvrrN0myCBbdWkVG1JVA96g==", - "dev": true, - "peerDependencies": { - "tailwindcss": ">=3.0.0" - } - }, - "node_modules/@sveltejs/adapter-cloudflare": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-cloudflare/-/adapter-cloudflare-4.1.0.tgz", - "integrity": "sha512-AQQdZAZpcFDcBiMEmxbMYhn4yKZYoPZrKUrYpVejjbO+9obIGIuj/jWjVzAEkHqZMZuoRRqPbq+Zq+AWRm4x1Q==", - "dev": true, - "dependencies": { - "@cloudflare/workers-types": "^4.20231121.0", - "esbuild": "^0.19.11", - "worktop": "0.8.0-next.18" - }, - "peerDependencies": { - "@sveltejs/kit": "^2.0.0" - } - }, - "node_modules/@sveltejs/kit": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz", - "integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@types/cookie": "^0.6.0", - "cookie": "^0.6.0", - "devalue": "^4.3.2", - "esm-env": "^1.0.0", - "import-meta-resolve": "^4.0.0", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "mrmime": "^2.0.0", - "sade": "^1.8.1", - "set-cookie-parser": "^2.6.0", - "sirv": "^2.0.4", - "tiny-glob": "^0.2.9" - }, - "bin": { - "svelte-kit": "svelte-kit.js" - }, - "engines": { - "node": ">=18.13" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.2.tgz", - "integrity": "sha512-MpmF/cju2HqUls50WyTHQBZUV3ovV/Uk8k66AN2gwHogNAG8wnW8xtZDhzNBsFJJuvmq1qnzA5kE7YfMJNFv2Q==", - "dev": true, - "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^2.0.0", - "debug": "^4.3.4", - "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "svelte-hmr": "^0.15.3", - "vitefu": "^0.2.5" - }, - "engines": { - "node": "^18.0.0 || >=20" - }, - "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.0.0.tgz", - "integrity": "sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==", - "dev": true, - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.0.0 || >=20" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.0" - } - }, - "node_modules/@tailwindcss/typography": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.10.tgz", - "integrity": "sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==", - "dev": true, - "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", - "postcss-selector-parser": "6.0.10" - }, - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true - }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.0.tgz", - "integrity": "sha512-FlsN0p4FhuYRjIxpbdXovvHQhtlG05O1GG/RNWvdAxTboR438IOTwmrY/vLA+Xfgg06BTkP045M3vpFwTMv1dg==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true - }, - "node_modules/@types/marked": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", - "integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==" - }, - "node_modules/@types/mdast": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.3.tgz", - "integrity": "sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/ms": { - "version": "0.7.34", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", - "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" - }, - "node_modules/@types/node": { - "version": "20.11.16", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.16.tgz", - "integrity": "sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==", - "dev": true, - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/pug": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", - "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", - "dev": true - }, - "node_modules/@types/semver": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", - "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", - "dev": true - }, - "node_modules/@types/unist": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", - "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", - "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", - "dev": true, - "dependencies": { - "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/type-utils": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.4", - "natural-compare": "^1.4.0", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", - "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", - "dev": true, - "dependencies": { - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/utils": "6.21.0", - "debug": "^4.3.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", - "dev": true, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", - "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@types/json-schema": "^7.0.12", - "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "semver": "^7.5.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", - "dev": true, - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" - }, - "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.17", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.22.2", - "caniuse-lite": "^1.0.30001578", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.0", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/axobject-query": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", - "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/bail": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", - "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/before-after-hook": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", - "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==" - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.3.tgz", - "integrity": "sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001580", - "electron-to-chromium": "^1.4.648", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001585", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001585.tgz", - "integrity": "sha512-yr2BWR1yLXQ8fMpdS/4ZZXpseBgE7o4g41x3a6AJOqZuOi+iE/WdJYAuZ6Y95i4Ohd2Y+9MzIWRR+uGABH4s3Q==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/character-entities": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", - "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/code-red": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", - "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", - "@types/estree": "^1.0.1", - "acorn": "^8.10.0", - "estree-walker": "^3.0.3", - "periscopic": "^3.1.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, - "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/date-fns": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.3.1.tgz", - "integrity": "sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/kossnocorp" - } - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decode-named-character-reference": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", - "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", - "dependencies": { - "character-entities": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/deprecation": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", - "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz", - "integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==", - "dev": true - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true - }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/electron-to-chromium": { - "version": "1.4.661", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.661.tgz", - "integrity": "sha512-AFg4wDHSOk5F+zA8aR+SVIOabu7m0e7BiJnigCvPXzIGy731XENw/lmNxTySpVFtkFEy+eyt4oHhh5FF3NjQNw==", - "dev": true - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/es6-promise": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", - "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", - "dev": true - }, - "node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" - } - }, - "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.56.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", - "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.56.0", - "@humanwhocodes/config-array": "^0.11.13", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-compat-utils": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.1.2.tgz", - "integrity": "sha512-Jia4JDldWnFNIru1Ehx1H5s9/yxiRHY/TimCuUc0jNexew3cF1gI6CYZil1ociakfWO3rRqFjl1mskBblB3RYg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/eslint-config-prettier": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", - "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", - "dev": true, - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-svelte": { - "version": "2.35.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-svelte/-/eslint-plugin-svelte-2.35.1.tgz", - "integrity": "sha512-IF8TpLnROSGy98Z3NrsKXWDSCbNY2ReHDcrYTuXZMbfX7VmESISR78TWgO9zdg4Dht1X8coub5jKwHzP0ExRug==", - "dev": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@jridgewell/sourcemap-codec": "^1.4.14", - "debug": "^4.3.1", - "eslint-compat-utils": "^0.1.2", - "esutils": "^2.0.3", - "known-css-properties": "^0.29.0", - "postcss": "^8.4.5", - "postcss-load-config": "^3.1.4", - "postcss-safe-parser": "^6.0.0", - "postcss-selector-parser": "^6.0.11", - "semver": "^7.5.3", - "svelte-eslint-parser": ">=0.33.0 <1.0.0" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0-0", - "svelte": "^3.37.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-svelte/node_modules/postcss-selector-parser": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", - "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/esm-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", - "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", - "dev": true - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true - }, - "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true - }, - "node_modules/fastq": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", - "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.2.9", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", - "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==", - "dev": true - }, - "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/github-slugger": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", - "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalyzer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", - "dev": true - }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", - "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hast-util-heading-rank": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", - "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-is-element": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", - "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-to-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.0.tgz", - "integrity": "sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/highlight.js": { - "version": "11.9.0", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz", - "integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-meta-resolve": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", - "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-absolute-url": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-4.0.1.tgz", - "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "engines": { - "node": ">=4" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-reference": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", - "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "dev": true, - "bin": { - "jiti": "bin/jiti.js" - } - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true - }, - "node_modules/just-camel-case": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/just-camel-case/-/just-camel-case-4.0.2.tgz", - "integrity": "sha512-df6QI/EIq+6uHe/wtaa9Qq7/pp4wr4pJC/r1+7XhVL6m5j03G6h9u9/rIZr8rDASX7CxwDPQnZjffCo2e6PRLw==" - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/known-css-properties": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz", - "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", - "dev": true - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", - "dev": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true - }, - "node_modules/longest-streak": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", - "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/lucide-svelte": { - "version": "0.323.0", - "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.323.0.tgz", - "integrity": "sha512-3GEFk1vCwB8BtHTHZTocFJfX6AtTLQw9a74JSuihAGx+MzhxqeWk8W1TkM4WUlvE0x9UdONM2rJGRyx9IyjkJg==", - "peerDependencies": { - "svelte": "^3 || ^4 || ^5.0.0-next.42" - } - }, - "node_modules/magic-string": { - "version": "0.30.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", - "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/markdown-table": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz", - "integrity": "sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/marked": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/marked/-/marked-5.1.2.tgz", - "integrity": "sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==", - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 16" - } - }, - "node_modules/mdast-util-definitions": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", - "integrity": "sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==", - "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", - "unist-util-visit": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-definitions/node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/mdast-util-definitions/node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-definitions/node_modules/unist-util-visit": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", - "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^5.1.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-definitions/node_modules/unist-util-visit-parents": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", - "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.1.tgz", - "integrity": "sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "escape-string-regexp": "^5.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", - "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mdast-util-from-markdown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz", - "integrity": "sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark": "^4.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-decode-string": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-from-markdown/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/mdast-util-from-markdown/node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.0.0.tgz", - "integrity": "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==", - "dependencies": { - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-gfm-autolink-literal": "^2.0.0", - "mdast-util-gfm-footnote": "^2.0.0", - "mdast-util-gfm-strikethrough": "^2.0.0", - "mdast-util-gfm-table": "^2.0.0", - "mdast-util-gfm-task-list-item": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-autolink-literal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.0.tgz", - "integrity": "sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "ccount": "^2.0.0", - "devlop": "^1.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "micromark-util-character": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-footnote": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.0.0.tgz", - "integrity": "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.1.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", - "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "markdown-table": "^3.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-gfm-task-list-item": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", - "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "devlop": "^1.0.0", - "mdast-util-from-markdown": "^2.0.0", - "mdast-util-to-markdown": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-phrasing": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", - "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", - "dependencies": { - "@types/mdast": "^4.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.0.tgz", - "integrity": "sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==", - "dependencies": { - "@types/mdast": "^4.0.0", - "@types/unist": "^3.0.0", - "longest-streak": "^3.0.0", - "mdast-util-phrasing": "^4.0.0", - "mdast-util-to-string": "^4.0.0", - "micromark-util-decode-string": "^2.0.0", - "unist-util-visit": "^5.0.0", - "zwitch": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-to-markdown/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/mdast-util-to-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", - "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", - "dependencies": { - "@types/mdast": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==" - }, - "node_modules/mdsvex": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/mdsvex/-/mdsvex-0.11.0.tgz", - "integrity": "sha512-gJF1s0N2nCmdxcKn8HDn0LKrN8poStqAicp6bBcsKFd/zkUBGLP5e7vnxu+g0pjBbDFOscUyI1mtHz+YK2TCDw==", - "dev": true, - "dependencies": { - "@types/unist": "^2.0.3", - "prism-svelte": "^0.4.7", - "prismjs": "^1.17.1", - "vfile-message": "^2.0.4" - }, - "peerDependencies": { - "svelte": ">=3 <5" - } - }, - "node_modules/mdsvex-relative-images": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/mdsvex-relative-images/-/mdsvex-relative-images-1.0.3.tgz", - "integrity": "sha512-3XvpnaguRAhC5gchpqCH+A5Yl28xG9WDPylVla0+k90c5LT+QqSM+hwHd1v5C7gB2cAT0AIhuMsY/g6aCw+WDg==", - "dependencies": { - "just-camel-case": "^4.0.2", - "unist-util-visit": "^3.1.0" - } - }, - "node_modules/mdsvex-relative-images/node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdsvex-relative-images/node_modules/unist-util-visit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-3.1.0.tgz", - "integrity": "sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdsvex-relative-images/node_modules/unist-util-visit-parents": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-4.1.1.tgz", - "integrity": "sha512-1xAFJXAKpnnJl8G7K5KgU7FY55y3GcLIXqkzUj5QF/QVP7biUm0K0O2oqVkYsdjzJKifYeWn9+o6piAK2hGSHw==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromark": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.0.tgz", - "integrity": "sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-core-commonmark": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.0.tgz", - "integrity": "sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "devlop": "^1.0.0", - "micromark-factory-destination": "^2.0.0", - "micromark-factory-label": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-factory-title": "^2.0.0", - "micromark-factory-whitespace": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-html-tag-name": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-subtokenize": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-extension-gfm": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", - "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", - "dependencies": { - "micromark-extension-gfm-autolink-literal": "^2.0.0", - "micromark-extension-gfm-footnote": "^2.0.0", - "micromark-extension-gfm-strikethrough": "^2.0.0", - "micromark-extension-gfm-table": "^2.0.0", - "micromark-extension-gfm-tagfilter": "^2.0.0", - "micromark-extension-gfm-task-list-item": "^2.0.0", - "micromark-util-combine-extensions": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-autolink-literal": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.0.0.tgz", - "integrity": "sha512-rTHfnpt/Q7dEAK1Y5ii0W8bhfJlVJFnJMHIPisfPK3gpVNuOP0VnRl96+YJ3RYWV/P4gFeQoGKNlT3RhuvpqAg==", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-footnote": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.0.0.tgz", - "integrity": "sha512-6Rzu0CYRKDv3BfLAUnZsSlzx3ak6HAoI85KTiijuKIz5UxZxbUI+pD6oHgw+6UtQuiRwnGRhzMmPRv4smcz0fg==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-core-commonmark": "^2.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-normalize-identifier": "^2.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-strikethrough": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.0.0.tgz", - "integrity": "sha512-c3BR1ClMp5fxxmwP6AoOY2fXO9U8uFMKs4ADD66ahLTNcwzSCyRVU4k7LPV5Nxo/VJiR4TdzxRQY2v3qIUceCw==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-classify-character": "^2.0.0", - "micromark-util-resolve-all": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-table": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.0.0.tgz", - "integrity": "sha512-PoHlhypg1ItIucOaHmKE8fbin3vTLpDOUg8KAr8gRCF1MOZI9Nquq2i/44wFvviM4WuxJzc3demT8Y3dkfvYrw==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-tagfilter": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", - "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", - "dependencies": { - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-extension-gfm-task-list-item": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.0.1.tgz", - "integrity": "sha512-cY5PzGcnULaN5O7T+cOzfMoHjBW7j+T9D2sucA5d/KbsBTPcYdebm9zUd9zzdgJGCwahV+/W78Z3nbulBYVbTw==", - "dependencies": { - "devlop": "^1.0.0", - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-factory-destination": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.0.tgz", - "integrity": "sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-label": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.0.tgz", - "integrity": "sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-space": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.0.tgz", - "integrity": "sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-title": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.0.tgz", - "integrity": "sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-factory-whitespace": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.0.tgz", - "integrity": "sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-factory-space": "^2.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", - "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-chunked": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.0.tgz", - "integrity": "sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-classify-character": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.0.tgz", - "integrity": "sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-combine-extensions": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.0.tgz", - "integrity": "sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-chunked": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-numeric-character-reference": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.1.tgz", - "integrity": "sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-decode-string": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.0.tgz", - "integrity": "sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^2.0.0", - "micromark-util-decode-numeric-character-reference": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", - "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-html-tag-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.0.tgz", - "integrity": "sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-normalize-identifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.0.tgz", - "integrity": "sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-resolve-all": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.0.tgz", - "integrity": "sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", - "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-subtokenize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.0.0.tgz", - "integrity": "sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "devlop": "^1.0.0", - "micromark-util-chunked": "^2.0.0", - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", - "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", - "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/mrmime": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", - "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true - }, - "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", - "dev": true, - "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true, - "engines": { - "node": "14 || >=16.14" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/periscopic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", - "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^3.0.0", - "is-reference": "^3.0.0" - } - }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/playwright": { - "version": "1.41.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.41.2.tgz", - "integrity": "sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==", - "dev": true, - "dependencies": { - "playwright-core": "1.41.2" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.41.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.41.2.tgz", - "integrity": "sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==", - "dev": true, - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/postcss": { - "version": "8.4.35", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", - "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-3.1.4.tgz", - "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", - "dev": true, - "dependencies": { - "lilconfig": "^2.0.5", - "yaml": "^1.10.2" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", - "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", - "dev": true, - "dependencies": { - "postcss-selector-parser": "^6.0.11" - }, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", - "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-safe-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", - "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", - "dev": true, - "engines": { - "node": ">=12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.3.3" - } - }, - "node_modules/postcss-scss": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", - "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-scss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.4.29" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.0.10", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", - "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", - "dev": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-plugin-svelte": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.1.2.tgz", - "integrity": "sha512-7xfMZtwgAWHMT0iZc8jN4o65zgbAQ3+O32V6W7pXrqNvKnHnkoyQCGCbKeUyXKZLbYE0YhFRnamfxfkEGxm8qA==", - "dev": true, - "peerDependencies": { - "prettier": "^3.0.0", - "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" - } - }, - "node_modules/prism-svelte": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/prism-svelte/-/prism-svelte-0.4.7.tgz", - "integrity": "sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==", - "dev": true - }, - "node_modules/prismjs": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", - "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/purgecss": { - "version": "6.0.0-alpha.0", - "resolved": "https://registry.npmjs.org/purgecss/-/purgecss-6.0.0-alpha.0.tgz", - "integrity": "sha512-UC7d7uIyZsky+srEsSXny9BkbTcVn3ZtBCNX3rW3DsqJKhvUXFRpufA4ktcHzWF0+JLZgmsqjUm/8R82x9bHpw==", - "dev": true, - "dependencies": { - "commander": "^10.0.0", - "glob": "^8.0.3", - "postcss": "^8.4.4", - "postcss-selector-parser": "^6.0.7" - }, - "bin": { - "purgecss": "bin/purgecss.js" - } - }, - "node_modules/purgecss/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "dev": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/purgecss/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/purgecss/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/reading-time": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/reading-time/-/reading-time-1.5.0.tgz", - "integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==" - }, - "node_modules/regexparam": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", - "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/rehype-autolink-headings": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz", - "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==", - "dependencies": { - "@types/hast": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-heading-rank": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-slug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", - "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", - "dependencies": { - "@types/hast": "^3.0.0", - "github-slugger": "^2.0.0", - "hast-util-heading-rank": "^3.0.0", - "hast-util-to-string": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-container": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/remark-container/-/remark-container-0.1.2.tgz", - "integrity": "sha512-E+G7dSALm3aMqyi15N4DxnRFQmBbHwxVc+9GrbijqwbdHzagUDvi2A3oI27y/PwLkSDRjwMfoc1rCIZayZ2PFg==" - }, - "node_modules/remark-external-links": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/remark-external-links/-/remark-external-links-9.0.1.tgz", - "integrity": "sha512-EYw+p8Zqy5oT5+W8iSKzInfRLY+zeKWHCf0ut+Q5SwnaSIDGXd2zzvp4SWqyAuVbinNmZ0zjMrDKaExWZnTYqQ==", - "dependencies": { - "@types/hast": "^2.3.2", - "@types/mdast": "^3.0.0", - "extend": "^3.0.0", - "is-absolute-url": "^4.0.0", - "mdast-util-definitions": "^5.0.0", - "space-separated-tokens": "^2.0.0", - "unified": "^10.0.0", - "unist-util-visit": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/remark-external-links/node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/remark-external-links/node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/remark-external-links/node_modules/unified": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", - "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", - "dependencies": { - "@types/unist": "^2.0.0", - "bail": "^2.0.0", - "extend": "^3.0.0", - "is-buffer": "^2.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/unist-util-stringify-position": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", - "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/unist-util-visit": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", - "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^5.1.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/unist-util-visit-parents": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", - "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/vfile": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", - "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", - "dependencies": { - "@types/unist": "^2.0.0", - "is-buffer": "^2.0.0", - "unist-util-stringify-position": "^3.0.0", - "vfile-message": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-external-links/node_modules/vfile-message": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", - "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-gfm": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.0.tgz", - "integrity": "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-gfm": "^3.0.0", - "micromark-extension-gfm": "^3.0.0", - "remark-parse": "^11.0.0", - "remark-stringify": "^11.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-github": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/remark-github/-/remark-github-12.0.0.tgz", - "integrity": "sha512-ByefQKFN184LeiGRCabfl7zUJsdlMYWEhiLX1gpmQ11yFg6xSuOTW7LVCv0oc1x+YvUMJW23NU36sJX2RWGgvg==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-find-and-replace": "^3.0.0", - "mdast-util-to-string": "^4.0.0", - "to-vfile": "^8.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-parse": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", - "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-from-markdown": "^2.0.0", - "micromark-util-types": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-reading-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/remark-reading-time/-/remark-reading-time-1.0.1.tgz", - "integrity": "sha512-Z3yW1JSNgQcjpPavsKmWgY7wmqRQMXIKoh8r5RtvJdpDIWWf7O7MkhuFDZh+Ge/1Olv0tvD1pN4T7LEhwBQnUA==", - "dependencies": { - "reading-time": "^1.3.0", - "unist-util-visit": "^3.1.0" - } - }, - "node_modules/remark-reading-time/node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-reading-time/node_modules/unist-util-visit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-3.1.0.tgz", - "integrity": "sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-reading-time/node_modules/unist-util-visit-parents": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-4.1.1.tgz", - "integrity": "sha512-1xAFJXAKpnnJl8G7K5KgU7FY55y3GcLIXqkzUj5QF/QVP7biUm0K0O2oqVkYsdjzJKifYeWn9+o6piAK2hGSHw==", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/remark-stringify": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", - "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", - "dependencies": { - "@types/mdast": "^4.0.0", - "mdast-util-to-markdown": "^2.0.0", - "unified": "^11.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.9.6", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz", - "integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.5" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.9.6", - "@rollup/rollup-android-arm64": "4.9.6", - "@rollup/rollup-darwin-arm64": "4.9.6", - "@rollup/rollup-darwin-x64": "4.9.6", - "@rollup/rollup-linux-arm-gnueabihf": "4.9.6", - "@rollup/rollup-linux-arm64-gnu": "4.9.6", - "@rollup/rollup-linux-arm64-musl": "4.9.6", - "@rollup/rollup-linux-riscv64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-gnu": "4.9.6", - "@rollup/rollup-linux-x64-musl": "4.9.6", - "@rollup/rollup-win32-arm64-msvc": "4.9.6", - "@rollup/rollup-win32-ia32-msvc": "4.9.6", - "@rollup/rollup-win32-x64-msvc": "4.9.6", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "dev": true, - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/sander": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", - "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==", - "dev": true, - "dependencies": { - "es6-promise": "^3.1.2", - "graceful-fs": "^4.1.3", - "mkdirp": "^0.5.1", - "rimraf": "^2.5.2" - } - }, - "node_modules/sander/node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-cookie-parser": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", - "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", - "dev": true - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "dev": true, - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/sorcery": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", - "integrity": "sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.14", - "buffer-crc32": "^0.2.5", - "minimist": "^1.2.0", - "sander": "^0.5.0" - }, - "bin": { - "sorcery": "bin/sorcery" - } - }, - "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/svelte": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.10.tgz", - "integrity": "sha512-Ep06yCaCdgG1Mafb/Rx8sJ1QS3RW2I2BxGp2Ui9LBHSZ2/tO/aGLc5WqPjgiAP6KAnLJGaIr/zzwQlOo1b8MxA==", - "dependencies": { - "@ampproject/remapping": "^2.2.1", - "@jridgewell/sourcemap-codec": "^1.4.15", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/estree": "^1.0.1", - "acorn": "^8.9.0", - "aria-query": "^5.3.0", - "axobject-query": "^4.0.0", - "code-red": "^1.0.3", - "css-tree": "^2.3.1", - "estree-walker": "^3.0.3", - "is-reference": "^3.0.1", - "locate-character": "^3.0.0", - "magic-string": "^0.30.4", - "periscopic": "^3.1.0" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/svelte-check": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.3.tgz", - "integrity": "sha512-Q2nGnoysxUnB9KjnjpQLZwdjK62DHyW6nuH/gm2qteFnDk0lCehe/6z8TsIvYeKjC6luKaWxiNGyOcWiLLPSwA==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", - "chokidar": "^3.4.1", - "fast-glob": "^3.2.7", - "import-fresh": "^3.2.1", - "picocolors": "^1.0.0", - "sade": "^1.7.4", - "svelte-preprocess": "^5.1.0", - "typescript": "^5.0.3" - }, - "bin": { - "svelte-check": "bin/svelte-check" - }, - "peerDependencies": { - "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" - } - }, - "node_modules/svelte-eslint-parser": { - "version": "0.33.1", - "resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.33.1.tgz", - "integrity": "sha512-vo7xPGTlKBGdLH8T5L64FipvTrqv3OQRx9d2z5X05KKZDlF4rQk8KViZO4flKERY+5BiVdOh7zZ7JGJWo5P0uA==", - "dev": true, - "dependencies": { - "eslint-scope": "^7.0.0", - "eslint-visitor-keys": "^3.0.0", - "espree": "^9.0.0", - "postcss": "^8.4.29", - "postcss-scss": "^4.0.8" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ota-meshi" - }, - "peerDependencies": { - "svelte": "^3.37.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - } - } - }, - "node_modules/svelte-fa": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/svelte-fa/-/svelte-fa-4.0.2.tgz", - "integrity": "sha512-lza8Jfii6jcpMQB73mBStONxaLfZsUS+rKJ/hH6WxsHUd+g68+oHIL9yQTk4a0uY9HQk78T/CPvQnED0msqJfg==", - "peerDependencies": { - "svelte": "^4.0.0" - } - }, - "node_modules/svelte-hmr": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", - "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==", - "dev": true, - "engines": { - "node": "^12.20 || ^14.13.1 || >= 16" - }, - "peerDependencies": { - "svelte": "^3.19.0 || ^4.0.0" - } - }, - "node_modules/svelte-markdown": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/svelte-markdown/-/svelte-markdown-0.4.1.tgz", - "integrity": "sha512-pOlLY6EruKJaWI9my/2bKX8PdTeP5CM0s4VMmwmC2prlOkjAf+AOmTM4wW/l19Y6WZ87YmP8+ZCJCCwBChWjYw==", - "dependencies": { - "@types/marked": "^5.0.1", - "marked": "^5.1.2" - }, - "peerDependencies": { - "svelte": "^4.0.0" - } - }, - "node_modules/svelte-preprocess": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz", - "integrity": "sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==", - "dev": true, - "hasInstallScript": true, - "dependencies": { - "@types/pug": "^2.0.6", - "detect-indent": "^6.1.0", - "magic-string": "^0.30.5", - "sorcery": "^0.11.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">= 16.0.0", - "pnpm": "^8.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.10.2", - "coffeescript": "^2.5.1", - "less": "^3.11.3 || ^4.0.0", - "postcss": "^7 || ^8", - "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", - "pug": "^3.0.0", - "sass": "^1.26.8", - "stylus": "^0.55.0", - "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", - "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "coffeescript": { - "optional": true - }, - "less": { - "optional": true - }, - "postcss": { - "optional": true - }, - "postcss-load-config": { - "optional": true - }, - "pug": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/tailwindcss": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", - "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", - "dev": true, - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.19.1", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss/node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/tailwindcss/node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", - "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", - "dev": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", - "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/tailwindcss/node_modules/yaml": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", - "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", - "dev": true, - "engines": { - "node": ">= 14" - } - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tiny-glob": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", - "dev": true, - "dependencies": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/to-vfile": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/to-vfile/-/to-vfile-8.0.0.tgz", - "integrity": "sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg==", - "dependencies": { - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/trough": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", - "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/ts-api-utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz", - "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true - }, - "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true - }, - "node_modules/unified": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.4.tgz", - "integrity": "sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ==", - "dependencies": { - "@types/unist": "^3.0.0", - "bail": "^2.0.0", - "devlop": "^1.0.0", - "extend": "^3.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unified/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/unified/node_modules/is-plain-obj": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-is/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/unist-util-stringify-position": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", - "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", - "dev": true, - "dependencies": { - "@types/unist": "^2.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", - "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/unist-util-visit/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/universal-user-agent": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", - "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==" - }, - "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true - }, - "node_modules/vfile": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz", - "integrity": "sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", - "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", - "dev": true, - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile/node_modules/@types/unist": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", - "integrity": "sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==" - }, - "node_modules/vfile/node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile/node_modules/vfile-message": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", - "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.0.tgz", - "integrity": "sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==", - "dev": true, - "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.35", - "rollup": "^4.2.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-plugin-tailwind-purgecss": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-tailwind-purgecss/-/vite-plugin-tailwind-purgecss-0.2.0.tgz", - "integrity": "sha512-6Q+SaalUd0t3BOIIiCQPlbZQuYARVgjoC78X+fLbQJqIEy/9fC58aQgHMgi+CmYfVfZmJToA8YiLueSGEo2mng==", - "dev": true, - "dependencies": { - "estree-walker": "^3.0.3", - "purgecss": "6.0.0-alpha.0" - }, - "peerDependencies": { - "vite": "^4.1.1 || ^5.0.0" - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vitefu": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", - "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", - "dev": true, - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/worktop": { - "version": "0.8.0-next.18", - "resolved": "https://registry.npmjs.org/worktop/-/worktop-0.8.0-next.18.tgz", - "integrity": "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==", - "dev": true, - "dependencies": { - "mrmime": "^2.0.0", - "regexparam": "^3.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, - "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/website/package.json b/website/package.json index cab83dd1..acb9f507 100644 --- a/website/package.json +++ b/website/package.json @@ -14,38 +14,38 @@ "format": "prettier --write ." }, "devDependencies": { - "@playwright/test": "^1.28.1", + "@playwright/test": "^1.41.2", "@skeletonlabs/skeleton": "2.8.0", "@skeletonlabs/tw-plugin": "0.3.1", "@sveltejs/adapter-cloudflare": "^4.1.0", - "@sveltejs/kit": "^2.0.0", - "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltejs/kit": "^2.5.0", + "@sveltejs/vite-plugin-svelte": "^3.0.2", "@tailwindcss/typography": "0.5.10", "@types/eslint": "8.56.0", "@types/node": "^20.11.16", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", "autoprefixer": "10.4.17", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-svelte": "^2.35.1", "mdsvex": "^0.11.0", "postcss": "8.4.35", - "prettier": "^3.1.1", + "prettier": "^3.2.5", "prettier-plugin-svelte": "^3.1.2", - "svelte": "^4.2.7", - "svelte-check": "^3.6.0", + "svelte": "^4.2.10", + "svelte-check": "^3.6.3", "tailwindcss": "3.4.1", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^5.0.3", + "tslib": "^2.6.2", + "typescript": "^5.3.3", + "vite": "^5.1.0", "vite-plugin-tailwind-purgecss": "0.2.0" }, "dependencies": { "@floating-ui/dom": "1.6.1", "@fortawesome/free-brands-svg-icons": "^6.5.1", - "@octokit/openapi-types": "^19.1.0", - "@octokit/rest": "^20.0.2", + "@octokit/openapi-types": "^22.2.0", + "@octokit/rest": "^21.0.2", "date-fns": "^3.3.1", "highlight.js": "11.9.0", "lucide-svelte": "^0.323.0", diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml new file mode 100644 index 00000000..d2e9f5fe --- /dev/null +++ b/website/pnpm-lock.yaml @@ -0,0 +1,4153 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@floating-ui/dom': + specifier: 1.6.1 + version: 1.6.1 + '@fortawesome/free-brands-svg-icons': + specifier: ^6.5.1 + version: 6.5.1 + '@octokit/openapi-types': + specifier: ^22.2.0 + version: 22.2.0 + '@octokit/rest': + specifier: ^21.0.2 + version: 21.0.2 + date-fns: + specifier: ^3.3.1 + version: 3.3.1 + highlight.js: + specifier: 11.9.0 + version: 11.9.0 + lucide-svelte: + specifier: ^0.323.0 + version: 0.323.0(svelte@4.2.10) + mdsvex-relative-images: + specifier: ^1.0.3 + version: 1.0.3 + rehype-autolink-headings: + specifier: ^7.1.0 + version: 7.1.0 + rehype-slug: + specifier: ^6.0.0 + version: 6.0.0 + remark-container: + specifier: ^0.1.2 + version: 0.1.2 + remark-external-links: + specifier: ^9.0.1 + version: 9.0.1 + remark-gfm: + specifier: ^4.0.0 + version: 4.0.0 + remark-github: + specifier: ^12.0.0 + version: 12.0.0 + remark-reading-time: + specifier: ^1.0.1 + version: 1.0.1 + svelte-fa: + specifier: ^4.0.2 + version: 4.0.2(svelte@4.2.10) + svelte-markdown: + specifier: ^0.4.1 + version: 0.4.1(svelte@4.2.10) + devDependencies: + '@playwright/test': + specifier: ^1.41.2 + version: 1.41.2 + '@skeletonlabs/skeleton': + specifier: 2.8.0 + version: 2.8.0(svelte@4.2.10) + '@skeletonlabs/tw-plugin': + specifier: 0.3.1 + version: 0.3.1(tailwindcss@3.4.1) + '@sveltejs/adapter-cloudflare': + specifier: ^4.1.0 + version: 4.1.0(@sveltejs/kit@2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16))) + '@sveltejs/kit': + specifier: ^2.5.0 + version: 2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + '@sveltejs/vite-plugin-svelte': + specifier: ^3.0.2 + version: 3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + '@tailwindcss/typography': + specifier: 0.5.10 + version: 0.5.10(tailwindcss@3.4.1) + '@types/eslint': + specifier: 8.56.0 + version: 8.56.0 + '@types/node': + specifier: ^20.11.16 + version: 20.11.16 + '@typescript-eslint/eslint-plugin': + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/parser': + specifier: ^6.21.0 + version: 6.21.0(eslint@8.56.0)(typescript@5.3.3) + autoprefixer: + specifier: 10.4.17 + version: 10.4.17(postcss@8.4.35) + eslint: + specifier: ^8.56.0 + version: 8.56.0 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.56.0) + eslint-plugin-svelte: + specifier: ^2.35.1 + version: 2.35.1(eslint@8.56.0)(svelte@4.2.10) + mdsvex: + specifier: ^0.11.0 + version: 0.11.0(svelte@4.2.10) + postcss: + specifier: 8.4.35 + version: 8.4.35 + prettier: + specifier: ^3.2.5 + version: 3.2.5 + prettier-plugin-svelte: + specifier: ^3.1.2 + version: 3.1.2(prettier@3.2.5)(svelte@4.2.10) + svelte: + specifier: ^4.2.10 + version: 4.2.10 + svelte-check: + specifier: ^3.6.3 + version: 3.6.3(postcss-load-config@4.0.2(postcss@8.4.35))(postcss@8.4.35)(svelte@4.2.10) + tailwindcss: + specifier: 3.4.1 + version: 3.4.1 + tslib: + specifier: ^2.6.2 + version: 2.6.2 + typescript: + specifier: ^5.3.3 + version: 5.3.3 + vite: + specifier: ^5.1.0 + version: 5.1.0(@types/node@20.11.16) + vite-plugin-tailwind-purgecss: + specifier: 0.2.0 + version: 0.2.0(vite@5.1.0(@types/node@20.11.16)) + +packages: + + '@aashutoshrathi/word-wrap@1.2.6': + resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} + engines: {node: '>=0.10.0'} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.2.1': + resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} + engines: {node: '>=6.0.0'} + + '@cloudflare/workers-types@4.20240208.0': + resolution: {integrity: sha512-MVGTTjZpJu4kJONvai5SdJzWIhOJbuweVZ3goI7FNyG+JdoQH41OoB+nMhLsX626vPLZVWGPIWsiSo/WZHzgQw==} + + '@esbuild/aix-ppc64@0.19.12': + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.19.12': + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.19.12': + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.19.12': + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.19.12': + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.19.12': + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.19.12': + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.19.12': + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.19.12': + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.19.12': + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.19.12': + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.19.12': + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.19.12': + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.19.12': + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.19.12': + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.19.12': + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.19.12': + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.19.12': + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.19.12': + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.19.12': + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.19.12': + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.19.12': + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.19.12': + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.10.0': + resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.56.0': + resolution: {integrity: sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@floating-ui/core@1.6.0': + resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==} + + '@floating-ui/dom@1.6.1': + resolution: {integrity: sha512-iA8qE43/H5iGozC3W0YSnVSW42Vh522yyM1gj+BqRwVsTNOyr231PsXDaV04yT39PsO0QL2QpbI/M0ZaLUQgRQ==} + + '@floating-ui/utils@0.2.1': + resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==} + + '@fortawesome/fontawesome-common-types@6.5.1': + resolution: {integrity: sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==} + engines: {node: '>=6'} + + '@fortawesome/free-brands-svg-icons@6.5.1': + resolution: {integrity: sha512-093l7DAkx0aEtBq66Sf19MgoZewv1zeY9/4C7vSKPO4qMwEsW/2VYTUTpBtLwfb9T2R73tXaRDPmE4UqLCYHfg==} + engines: {node: '>=6'} + + '@humanwhocodes/config-array@0.11.14': + resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.2': + resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} + deprecated: Use @eslint/object-schema instead + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.3': + resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.1': + resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.1.2': + resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.4.15': + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + '@jridgewell/trace-mapping@0.3.22': + resolution: {integrity: sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@octokit/auth-token@5.1.1': + resolution: {integrity: sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==} + engines: {node: '>= 18'} + + '@octokit/core@6.1.2': + resolution: {integrity: sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==} + engines: {node: '>= 18'} + + '@octokit/endpoint@10.1.1': + resolution: {integrity: sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==} + engines: {node: '>= 18'} + + '@octokit/graphql@8.1.1': + resolution: {integrity: sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==} + engines: {node: '>= 18'} + + '@octokit/openapi-types@22.2.0': + resolution: {integrity: sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==} + + '@octokit/plugin-paginate-rest@11.3.5': + resolution: {integrity: sha512-cgwIRtKrpwhLoBi0CUNuY83DPGRMaWVjqVI/bGKsLJ4PzyWZNaEmhHroI2xlrVXkk6nFv0IsZpOp+ZWSWUS2AQ==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-request-log@5.3.1': + resolution: {integrity: sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@13.2.6': + resolution: {integrity: sha512-wMsdyHMjSfKjGINkdGKki06VEkgdEldIGstIEyGX0wbYHGByOwN/KiM+hAAlUwAtPkP3gvXtVQA9L3ITdV2tVw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@6.1.5': + resolution: {integrity: sha512-IlBTfGX8Yn/oFPMwSfvugfncK2EwRLjzbrpifNaMY8o/HTEAFqCA1FZxjD9cWvSKBHgrIhc4CSBIzMxiLsbzFQ==} + engines: {node: '>= 18'} + + '@octokit/request@9.1.3': + resolution: {integrity: sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==} + engines: {node: '>= 18'} + + '@octokit/rest@21.0.2': + resolution: {integrity: sha512-+CiLisCoyWmYicH25y1cDfCrv41kRSvTq6pPWtRroRJzhsCZWZyCqGyI8foJT5LmScADSwRAnr/xo+eewL04wQ==} + engines: {node: '>= 18'} + + '@octokit/types@13.6.1': + resolution: {integrity: sha512-PHZE9Z+kWXb23Ndik8MKPirBPziOc0D2/3KH1P+6jK5nGWe96kadZuE4jev2/Jq7FvIfTlT2Ltg8Fv2x1v0a5g==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.41.2': + resolution: {integrity: sha512-qQB9h7KbibJzrDpkXkYvsmiDJK14FULCCZgEcoe2AvFAS64oCirWTwzTlAYEbKaRxWs5TFesE1Na6izMv3HfGg==} + engines: {node: '>=16'} + hasBin: true + + '@polka/url@1.0.0-next.24': + resolution: {integrity: sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==} + + '@rollup/rollup-android-arm-eabi@4.9.6': + resolution: {integrity: sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.9.6': + resolution: {integrity: sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.9.6': + resolution: {integrity: sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.9.6': + resolution: {integrity: sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-linux-arm-gnueabihf@4.9.6': + resolution: {integrity: sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.9.6': + resolution: {integrity: sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.9.6': + resolution: {integrity: sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.9.6': + resolution: {integrity: sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.9.6': + resolution: {integrity: sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.9.6': + resolution: {integrity: sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.9.6': + resolution: {integrity: sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.9.6': + resolution: {integrity: sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.9.6': + resolution: {integrity: sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==} + cpu: [x64] + os: [win32] + + '@skeletonlabs/skeleton@2.8.0': + resolution: {integrity: sha512-R6spSJSyW9MA6cnVQ8IV7uoYSXxHmP/oWJ9IHdGDU9epPZaZMmOXUHJSzA1gngccB2jFaA/6jXfS1O1CsIlGMg==} + peerDependencies: + svelte: ^3.56.0 || ^4.0.0 + + '@skeletonlabs/tw-plugin@0.3.1': + resolution: {integrity: sha512-DjjeOHN3HhFQf6gYPT2MUZMkIdw1jeB9mbuKC8etQxUlOR4XitfC7hssRWFJ8RJsvrrN0myCBbdWkVG1JVA96g==} + peerDependencies: + tailwindcss: '>=3.0.0' + + '@sveltejs/adapter-cloudflare@4.1.0': + resolution: {integrity: sha512-AQQdZAZpcFDcBiMEmxbMYhn4yKZYoPZrKUrYpVejjbO+9obIGIuj/jWjVzAEkHqZMZuoRRqPbq+Zq+AWRm4x1Q==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.5.0': + resolution: {integrity: sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^3.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.3 + + '@sveltejs/vite-plugin-svelte-inspector@2.0.0': + resolution: {integrity: sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==} + engines: {node: ^18.0.0 || >=20} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^3.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.0 + + '@sveltejs/vite-plugin-svelte@3.0.2': + resolution: {integrity: sha512-MpmF/cju2HqUls50WyTHQBZUV3ovV/Uk8k66AN2gwHogNAG8wnW8xtZDhzNBsFJJuvmq1qnzA5kE7YfMJNFv2Q==} + engines: {node: ^18.0.0 || >=20} + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.0 + + '@tailwindcss/typography@0.5.10': + resolution: {integrity: sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/eslint@8.56.0': + resolution: {integrity: sha512-FlsN0p4FhuYRjIxpbdXovvHQhtlG05O1GG/RNWvdAxTboR438IOTwmrY/vLA+Xfgg06BTkP045M3vpFwTMv1dg==} + + '@types/estree@1.0.5': + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + + '@types/hast@2.3.10': + resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/marked@5.0.2': + resolution: {integrity: sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==} + + '@types/mdast@3.0.15': + resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} + + '@types/mdast@4.0.3': + resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==} + + '@types/ms@0.7.34': + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + + '@types/node@20.11.16': + resolution: {integrity: sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==} + + '@types/pug@2.0.10': + resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + + '@types/semver@7.5.6': + resolution: {integrity: sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==} + + '@types/unist@2.0.10': + resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} + + '@types/unist@3.0.2': + resolution: {integrity: sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + autoprefixer@10.4.17: + resolution: {integrity: sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axobject-query@4.0.0: + resolution: {integrity: sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + before-after-hook@3.0.2: + resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} + + binary-extensions@2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + + browserslist@4.22.3: + resolution: {integrity: sha512-UAp55yfwNv0klWNapjs/ktHoguxuQNGnOzxYmfnXIS+8AsRDZkSDxg7R1AX3GKzn078SBI5dzwzj/Yx0Or0e3A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001668: + resolution: {integrity: sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + code-red@1.0.4: + resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + date-fns@3.3.1: + resolution: {integrity: sha512-y8e109LYGgoQDveiEBD3DYXKba1jWf5BA8YU1FL5Tvm0BTdEfy54WLCwnuYWZNnzzvALy/QQ4Hov+Q9RVRv+Zw==} + + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.0.2: + resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + devalue@4.3.2: + resolution: {integrity: sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.4.661: + resolution: {integrity: sha512-AFg4wDHSOk5F+zA8aR+SVIOabu7m0e7BiJnigCvPXzIGy731XENw/lmNxTySpVFtkFEy+eyt4oHhh5FF3NjQNw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + es6-promise@3.3.1: + resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} + + esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.1.2: + resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-compat-utils@0.1.2: + resolution: {integrity: sha512-Jia4JDldWnFNIru1Ehx1H5s9/yxiRHY/TimCuUc0jNexew3cF1gI6CYZil1ociakfWO3rRqFjl1mskBblB3RYg==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-svelte@2.35.1: + resolution: {integrity: sha512-IF8TpLnROSGy98Z3NrsKXWDSCbNY2ReHDcrYTuXZMbfX7VmESISR78TWgO9zdg4Dht1X8coub5jKwHzP0ExRug==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0-0 + svelte: ^3.37.0 || ^4.0.0 + peerDependenciesMeta: + svelte: + optional: true + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.56.0: + resolution: {integrity: sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + esm-env@1.0.0: + resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.2.9: + resolution: {integrity: sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==} + + foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} + engines: {node: '>=14'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalyzer@0.1.0: + resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.0: + resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} + engines: {node: '>= 0.4'} + + hast-util-heading-rank@3.0.0: + resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-to-string@3.0.0: + resolution: {integrity: sha512-OGkAxX1Ua3cbcW6EJ5pT/tslVb90uViVkcJ4ZZIMW/R33DX/AkcJcRrPebPwJkHYwlDHXz4aIwvAAaAdtrACFA==} + + highlight.js@11.9.0: + resolution: {integrity: sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==} + engines: {node: '>=12.0.0'} + + ignore@5.3.1: + resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + engines: {node: '>= 4'} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + import-meta-resolve@4.0.0: + resolution: {integrity: sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-absolute-url@4.0.1: + resolution: {integrity: sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + + is-core-module@2.13.1: + resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-reference@3.0.2: + resolution: {integrity: sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + + jiti@1.21.0: + resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + just-camel-case@4.0.2: + resolution: {integrity: sha512-df6QI/EIq+6uHe/wtaa9Qq7/pp4wr4pJC/r1+7XhVL6m5j03G6h9u9/rIZr8rDASX7CxwDPQnZjffCo2e6PRLw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + known-css-properties@0.29.0: + resolution: {integrity: sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lilconfig@3.0.0: + resolution: {integrity: sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.castarray@4.4.0: + resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + engines: {node: 14 || >=16.14} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lucide-svelte@0.323.0: + resolution: {integrity: sha512-3GEFk1vCwB8BtHTHZTocFJfX6AtTLQw9a74JSuihAGx+MzhxqeWk8W1TkM4WUlvE0x9UdONM2rJGRyx9IyjkJg==} + peerDependencies: + svelte: ^3 || ^4 || ^5.0.0-next.42 + + magic-string@0.30.7: + resolution: {integrity: sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==} + engines: {node: '>=12'} + + markdown-table@3.0.3: + resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} + + marked@5.1.2: + resolution: {integrity: sha512-ahRPGXJpjMjwSOlBoTMZAK7ATXkli5qCPxZ21TG44rx1KEo44bii4ekgTDQPNRQ4Kh7JMb9Ub1PVk1NxRSsorg==} + engines: {node: '>= 16'} + hasBin: true + + mdast-util-definitions@5.1.2: + resolution: {integrity: sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==} + + mdast-util-find-and-replace@3.0.1: + resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} + + mdast-util-from-markdown@2.0.0: + resolution: {integrity: sha512-n7MTOr/z+8NAX/wmhhDji8O3bRvPTV/U0oTCaZJkjhPSKTPhS3xufVhKGF8s1pJ7Ox4QgoIU7KHseh09S+9rTA==} + + mdast-util-gfm-autolink-literal@2.0.0: + resolution: {integrity: sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==} + + mdast-util-gfm-footnote@2.0.0: + resolution: {integrity: sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.0.0: + resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-markdown@2.1.0: + resolution: {integrity: sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + + mdsvex-relative-images@1.0.3: + resolution: {integrity: sha512-3XvpnaguRAhC5gchpqCH+A5Yl28xG9WDPylVla0+k90c5LT+QqSM+hwHd1v5C7gB2cAT0AIhuMsY/g6aCw+WDg==} + + mdsvex@0.11.0: + resolution: {integrity: sha512-gJF1s0N2nCmdxcKn8HDn0LKrN8poStqAicp6bBcsKFd/zkUBGLP5e7vnxu+g0pjBbDFOscUyI1mtHz+YK2TCDw==} + peerDependencies: + svelte: '>=3 <5' + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.0: + resolution: {integrity: sha512-jThOz/pVmAYUtkroV3D5c1osFXAMv9e0ypGDOIZuCeAe91/sD6BoE2Sjzt30yuXtwOYUmySOhMas/PVyh02itA==} + + micromark-extension-gfm-autolink-literal@2.0.0: + resolution: {integrity: sha512-rTHfnpt/Q7dEAK1Y5ii0W8bhfJlVJFnJMHIPisfPK3gpVNuOP0VnRl96+YJ3RYWV/P4gFeQoGKNlT3RhuvpqAg==} + + micromark-extension-gfm-footnote@2.0.0: + resolution: {integrity: sha512-6Rzu0CYRKDv3BfLAUnZsSlzx3ak6HAoI85KTiijuKIz5UxZxbUI+pD6oHgw+6UtQuiRwnGRhzMmPRv4smcz0fg==} + + micromark-extension-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-c3BR1ClMp5fxxmwP6AoOY2fXO9U8uFMKs4ADD66ahLTNcwzSCyRVU4k7LPV5Nxo/VJiR4TdzxRQY2v3qIUceCw==} + + micromark-extension-gfm-table@2.0.0: + resolution: {integrity: sha512-PoHlhypg1ItIucOaHmKE8fbin3vTLpDOUg8KAr8gRCF1MOZI9Nquq2i/44wFvviM4WuxJzc3demT8Y3dkfvYrw==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.0.1: + resolution: {integrity: sha512-cY5PzGcnULaN5O7T+cOzfMoHjBW7j+T9D2sucA5d/KbsBTPcYdebm9zUd9zzdgJGCwahV+/W78Z3nbulBYVbTw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.0: + resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} + + micromark-factory-label@2.0.0: + resolution: {integrity: sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==} + + micromark-factory-space@2.0.0: + resolution: {integrity: sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==} + + micromark-factory-title@2.0.0: + resolution: {integrity: sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==} + + micromark-factory-whitespace@2.0.0: + resolution: {integrity: sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==} + + micromark-util-character@2.1.0: + resolution: {integrity: sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==} + + micromark-util-chunked@2.0.0: + resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + + micromark-util-classify-character@2.0.0: + resolution: {integrity: sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==} + + micromark-util-combine-extensions@2.0.0: + resolution: {integrity: sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==} + + micromark-util-decode-numeric-character-reference@2.0.1: + resolution: {integrity: sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==} + + micromark-util-decode-string@2.0.0: + resolution: {integrity: sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==} + + micromark-util-encode@2.0.0: + resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + + micromark-util-html-tag-name@2.0.0: + resolution: {integrity: sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==} + + micromark-util-normalize-identifier@2.0.0: + resolution: {integrity: sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==} + + micromark-util-resolve-all@2.0.0: + resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + + micromark-util-sanitize-uri@2.0.0: + resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + + micromark-util-subtokenize@2.0.0: + resolution: {integrity: sha512-vc93L1t+gpR3p8jxeVdaYlbV2jTYteDje19rNSS/H5dlhxUYll5Fy6vJ2cDwP8RnsXi818yGty1ayP55y3W6fg==} + + micromark-util-symbol@2.0.0: + resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} + + micromark-util-types@2.0.0: + resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} + + micromark@4.0.0: + resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} + + micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.0.4: + resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.14: + resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.3: + resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.10.1: + resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} + engines: {node: '>=16 || 14 >=14.17'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + periscopic@3.1.0: + resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + + picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + playwright-core@1.41.2: + resolution: {integrity: sha512-VaTvwCA4Y8kxEe+kfm2+uUUw5Lubf38RxF7FpBxLPmGe5sdNkSg5e3ChEigaGrX7qdqT3pt2m/98LiyvU2x6CA==} + engines: {node: '>=16'} + hasBin: true + + playwright@1.41.2: + resolution: {integrity: sha512-v0bOa6H2GJChDL8pAeLa/LZC4feoAMbSQm1/jF/ySsWWoaNItvrMP7GEkvEEFyCTUYKMxjQKaTSg5up7nR6/8A==} + engines: {node: '>=16'} + hasBin: true + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-nested@6.0.1: + resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-safe-parser@6.0.0: + resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.3.3 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@6.0.15: + resolution: {integrity: sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.35: + resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-svelte@3.1.2: + resolution: {integrity: sha512-7xfMZtwgAWHMT0iZc8jN4o65zgbAQ3+O32V6W7pXrqNvKnHnkoyQCGCbKeUyXKZLbYE0YhFRnamfxfkEGxm8qA==} + peerDependencies: + prettier: ^3.0.0 + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 + + prettier@3.2.5: + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} + engines: {node: '>=14'} + hasBin: true + + prism-svelte@0.4.7: + resolution: {integrity: sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==} + + prismjs@1.29.0: + resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + purgecss@6.0.0-alpha.0: + resolution: {integrity: sha512-UC7d7uIyZsky+srEsSXny9BkbTcVn3ZtBCNX3rW3DsqJKhvUXFRpufA4ktcHzWF0+JLZgmsqjUm/8R82x9bHpw==} + hasBin: true + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + reading-time@1.5.0: + resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} + + regexparam@3.0.0: + resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} + engines: {node: '>=8'} + + rehype-autolink-headings@7.1.0: + resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} + + rehype-slug@6.0.0: + resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + + remark-container@0.1.2: + resolution: {integrity: sha512-E+G7dSALm3aMqyi15N4DxnRFQmBbHwxVc+9GrbijqwbdHzagUDvi2A3oI27y/PwLkSDRjwMfoc1rCIZayZ2PFg==} + + remark-external-links@9.0.1: + resolution: {integrity: sha512-EYw+p8Zqy5oT5+W8iSKzInfRLY+zeKWHCf0ut+Q5SwnaSIDGXd2zzvp4SWqyAuVbinNmZ0zjMrDKaExWZnTYqQ==} + + remark-gfm@4.0.0: + resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} + + remark-github@12.0.0: + resolution: {integrity: sha512-ByefQKFN184LeiGRCabfl7zUJsdlMYWEhiLX1gpmQ11yFg6xSuOTW7LVCv0oc1x+YvUMJW23NU36sJX2RWGgvg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-reading-time@1.0.1: + resolution: {integrity: sha512-Z3yW1JSNgQcjpPavsKmWgY7wmqRQMXIKoh8r5RtvJdpDIWWf7O7MkhuFDZh+Ge/1Olv0tvD1pN4T7LEhwBQnUA==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.9.6: + resolution: {integrity: sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + sander@0.5.1: + resolution: {integrity: sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==} + + semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.6.0: + resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + sorcery@0.11.0: + resolution: {integrity: sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==} + hasBin: true + + source-map-js@1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svelte-check@3.6.3: + resolution: {integrity: sha512-Q2nGnoysxUnB9KjnjpQLZwdjK62DHyW6nuH/gm2qteFnDk0lCehe/6z8TsIvYeKjC6luKaWxiNGyOcWiLLPSwA==} + hasBin: true + peerDependencies: + svelte: ^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 + + svelte-eslint-parser@0.33.1: + resolution: {integrity: sha512-vo7xPGTlKBGdLH8T5L64FipvTrqv3OQRx9d2z5X05KKZDlF4rQk8KViZO4flKERY+5BiVdOh7zZ7JGJWo5P0uA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + svelte: ^3.37.0 || ^4.0.0 + peerDependenciesMeta: + svelte: + optional: true + + svelte-fa@4.0.2: + resolution: {integrity: sha512-lza8Jfii6jcpMQB73mBStONxaLfZsUS+rKJ/hH6WxsHUd+g68+oHIL9yQTk4a0uY9HQk78T/CPvQnED0msqJfg==} + peerDependencies: + svelte: ^4.0.0 + + svelte-hmr@0.15.3: + resolution: {integrity: sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: ^3.19.0 || ^4.0.0 + + svelte-markdown@0.4.1: + resolution: {integrity: sha512-pOlLY6EruKJaWI9my/2bKX8PdTeP5CM0s4VMmwmC2prlOkjAf+AOmTM4wW/l19Y6WZ87YmP8+ZCJCCwBChWjYw==} + peerDependencies: + svelte: ^4.0.0 + + svelte-preprocess@5.1.3: + resolution: {integrity: sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==} + engines: {node: '>= 16.0.0', pnpm: ^8.0.0} + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 + svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 + typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + + svelte@4.2.10: + resolution: {integrity: sha512-Ep06yCaCdgG1Mafb/Rx8sJ1QS3RW2I2BxGp2Ui9LBHSZ2/tO/aGLc5WqPjgiAP6KAnLJGaIr/zzwQlOo1b8MxA==} + engines: {node: '>=16'} + + tailwindcss@3.4.1: + resolution: {integrity: sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==} + engines: {node: '>=14.0.0'} + hasBin: true + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tiny-glob@0.2.9: + resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + to-vfile@8.0.0: + resolution: {integrity: sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg==} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-api-utils@1.2.1: + resolution: {integrity: sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typescript@5.3.3: + resolution: {integrity: sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + unified@10.1.2: + resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} + + unified@11.0.4: + resolution: {integrity: sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ==} + + unist-util-is@5.2.1: + resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==} + + unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + + unist-util-stringify-position@2.0.3: + resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==} + + unist-util-stringify-position@3.0.3: + resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@4.1.1: + resolution: {integrity: sha512-1xAFJXAKpnnJl8G7K5KgU7FY55y3GcLIXqkzUj5QF/QVP7biUm0K0O2oqVkYsdjzJKifYeWn9+o6piAK2hGSHw==} + + unist-util-visit-parents@5.1.3: + resolution: {integrity: sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==} + + unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + + unist-util-visit@3.1.0: + resolution: {integrity: sha512-Szoh+R/Ll68QWAyQyZZpQzZQm2UPbxibDvaY8Xc9SUtYgPsDzx5AWSk++UUt2hJuow8mvwR+rG+LQLw+KsuAKA==} + + unist-util-visit@4.1.2: + resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + + universal-user-agent@7.0.2: + resolution: {integrity: sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==} + + update-browserslist-db@1.0.13: + resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-message@2.0.4: + resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==} + + vfile-message@3.1.4: + resolution: {integrity: sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==} + + vfile-message@4.0.2: + resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + + vfile@5.3.7: + resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} + + vfile@6.0.1: + resolution: {integrity: sha512-1bYqc7pt6NIADBJ98UiG0Bn/CHIVOoZ/IyEkqIruLg0mE1BKzkOXY2D6CSqQIcKqgadppE5lrxgWXJmXd7zZJw==} + + vite-plugin-tailwind-purgecss@0.2.0: + resolution: {integrity: sha512-6Q+SaalUd0t3BOIIiCQPlbZQuYARVgjoC78X+fLbQJqIEy/9fC58aQgHMgi+CmYfVfZmJToA8YiLueSGEo2mng==} + peerDependencies: + vite: ^4.1.1 || ^5.0.0 + + vite@5.1.0: + resolution: {integrity: sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitefu@0.2.5: + resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + vite: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + worktop@0.8.0-next.18: + resolution: {integrity: sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==} + engines: {node: '>=12'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yaml@2.3.4: + resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} + engines: {node: '>= 14'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@aashutoshrathi/word-wrap@1.2.6': {} + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.2.1': + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.22 + + '@cloudflare/workers-types@4.20240208.0': {} + + '@esbuild/aix-ppc64@0.19.12': + optional: true + + '@esbuild/android-arm64@0.19.12': + optional: true + + '@esbuild/android-arm@0.19.12': + optional: true + + '@esbuild/android-x64@0.19.12': + optional: true + + '@esbuild/darwin-arm64@0.19.12': + optional: true + + '@esbuild/darwin-x64@0.19.12': + optional: true + + '@esbuild/freebsd-arm64@0.19.12': + optional: true + + '@esbuild/freebsd-x64@0.19.12': + optional: true + + '@esbuild/linux-arm64@0.19.12': + optional: true + + '@esbuild/linux-arm@0.19.12': + optional: true + + '@esbuild/linux-ia32@0.19.12': + optional: true + + '@esbuild/linux-loong64@0.19.12': + optional: true + + '@esbuild/linux-mips64el@0.19.12': + optional: true + + '@esbuild/linux-ppc64@0.19.12': + optional: true + + '@esbuild/linux-riscv64@0.19.12': + optional: true + + '@esbuild/linux-s390x@0.19.12': + optional: true + + '@esbuild/linux-x64@0.19.12': + optional: true + + '@esbuild/netbsd-x64@0.19.12': + optional: true + + '@esbuild/openbsd-x64@0.19.12': + optional: true + + '@esbuild/sunos-x64@0.19.12': + optional: true + + '@esbuild/win32-arm64@0.19.12': + optional: true + + '@esbuild/win32-ia32@0.19.12': + optional: true + + '@esbuild/win32-x64@0.19.12': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@8.56.0)': + dependencies: + eslint: 8.56.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.10.0': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.3.4 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.1 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.56.0': {} + + '@floating-ui/core@1.6.0': + dependencies: + '@floating-ui/utils': 0.2.1 + + '@floating-ui/dom@1.6.1': + dependencies: + '@floating-ui/core': 1.6.0 + '@floating-ui/utils': 0.2.1 + + '@floating-ui/utils@0.2.1': {} + + '@fortawesome/fontawesome-common-types@6.5.1': {} + + '@fortawesome/free-brands-svg-icons@6.5.1': + dependencies: + '@fortawesome/fontawesome-common-types': 6.5.1 + + '@humanwhocodes/config-array@0.11.14': + dependencies: + '@humanwhocodes/object-schema': 2.0.2 + debug: 4.3.4 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.2': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.3': + dependencies: + '@jridgewell/set-array': 1.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.22 + + '@jridgewell/resolve-uri@3.1.1': {} + + '@jridgewell/set-array@1.1.2': {} + + '@jridgewell/sourcemap-codec@1.4.15': {} + + '@jridgewell/trace-mapping@0.3.22': + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.4.15 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@octokit/auth-token@5.1.1': {} + + '@octokit/core@6.1.2': + dependencies: + '@octokit/auth-token': 5.1.1 + '@octokit/graphql': 8.1.1 + '@octokit/request': 9.1.3 + '@octokit/request-error': 6.1.5 + '@octokit/types': 13.6.1 + before-after-hook: 3.0.2 + universal-user-agent: 7.0.2 + + '@octokit/endpoint@10.1.1': + dependencies: + '@octokit/types': 13.6.1 + universal-user-agent: 7.0.2 + + '@octokit/graphql@8.1.1': + dependencies: + '@octokit/request': 9.1.3 + '@octokit/types': 13.6.1 + universal-user-agent: 7.0.2 + + '@octokit/openapi-types@22.2.0': {} + + '@octokit/plugin-paginate-rest@11.3.5(@octokit/core@6.1.2)': + dependencies: + '@octokit/core': 6.1.2 + '@octokit/types': 13.6.1 + + '@octokit/plugin-request-log@5.3.1(@octokit/core@6.1.2)': + dependencies: + '@octokit/core': 6.1.2 + + '@octokit/plugin-rest-endpoint-methods@13.2.6(@octokit/core@6.1.2)': + dependencies: + '@octokit/core': 6.1.2 + '@octokit/types': 13.6.1 + + '@octokit/request-error@6.1.5': + dependencies: + '@octokit/types': 13.6.1 + + '@octokit/request@9.1.3': + dependencies: + '@octokit/endpoint': 10.1.1 + '@octokit/request-error': 6.1.5 + '@octokit/types': 13.6.1 + universal-user-agent: 7.0.2 + + '@octokit/rest@21.0.2': + dependencies: + '@octokit/core': 6.1.2 + '@octokit/plugin-paginate-rest': 11.3.5(@octokit/core@6.1.2) + '@octokit/plugin-request-log': 5.3.1(@octokit/core@6.1.2) + '@octokit/plugin-rest-endpoint-methods': 13.2.6(@octokit/core@6.1.2) + + '@octokit/types@13.6.1': + dependencies: + '@octokit/openapi-types': 22.2.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.41.2': + dependencies: + playwright: 1.41.2 + + '@polka/url@1.0.0-next.24': {} + + '@rollup/rollup-android-arm-eabi@4.9.6': + optional: true + + '@rollup/rollup-android-arm64@4.9.6': + optional: true + + '@rollup/rollup-darwin-arm64@4.9.6': + optional: true + + '@rollup/rollup-darwin-x64@4.9.6': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.9.6': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.9.6': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.9.6': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.9.6': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.9.6': + optional: true + + '@rollup/rollup-linux-x64-musl@4.9.6': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.9.6': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.9.6': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.9.6': + optional: true + + '@skeletonlabs/skeleton@2.8.0(svelte@4.2.10)': + dependencies: + esm-env: 1.0.0 + svelte: 4.2.10 + + '@skeletonlabs/tw-plugin@0.3.1(tailwindcss@3.4.1)': + dependencies: + tailwindcss: 3.4.1 + + '@sveltejs/adapter-cloudflare@4.1.0(@sveltejs/kit@2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))': + dependencies: + '@cloudflare/workers-types': 4.20240208.0 + '@sveltejs/kit': 2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + esbuild: 0.19.12 + worktop: 0.8.0-next.18 + + '@sveltejs/kit@2.5.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16))': + dependencies: + '@sveltejs/vite-plugin-svelte': 3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + '@types/cookie': 0.6.0 + cookie: 0.6.0 + devalue: 4.3.2 + esm-env: 1.0.0 + import-meta-resolve: 4.0.0 + kleur: 4.1.5 + magic-string: 0.30.7 + mrmime: 2.0.0 + sade: 1.8.1 + set-cookie-parser: 2.6.0 + sirv: 2.0.4 + svelte: 4.2.10 + tiny-glob: 0.2.9 + vite: 5.1.0(@types/node@20.11.16) + + '@sveltejs/vite-plugin-svelte-inspector@2.0.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16))': + dependencies: + '@sveltejs/vite-plugin-svelte': 3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + debug: 4.3.4 + svelte: 4.2.10 + vite: 5.1.0(@types/node@20.11.16) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 2.0.0(@sveltejs/vite-plugin-svelte@3.0.2(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)))(svelte@4.2.10)(vite@5.1.0(@types/node@20.11.16)) + debug: 4.3.4 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.7 + svelte: 4.2.10 + svelte-hmr: 0.15.3(svelte@4.2.10) + vite: 5.1.0(@types/node@20.11.16) + vitefu: 0.2.5(vite@5.1.0(@types/node@20.11.16)) + transitivePeerDependencies: + - supports-color + + '@tailwindcss/typography@0.5.10(tailwindcss@3.4.1)': + dependencies: + lodash.castarray: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.merge: 4.6.2 + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.1 + + '@types/cookie@0.6.0': {} + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 0.7.34 + + '@types/eslint@8.56.0': + dependencies: + '@types/estree': 1.0.5 + '@types/json-schema': 7.0.15 + + '@types/estree@1.0.5': {} + + '@types/hast@2.3.10': + dependencies: + '@types/unist': 2.0.10 + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.2 + + '@types/json-schema@7.0.15': {} + + '@types/marked@5.0.2': {} + + '@types/mdast@3.0.15': + dependencies: + '@types/unist': 2.0.10 + + '@types/mdast@4.0.3': + dependencies: + '@types/unist': 3.0.2 + + '@types/ms@0.7.34': {} + + '@types/node@20.11.16': + dependencies: + undici-types: 5.26.5 + + '@types/pug@2.0.10': {} + + '@types/semver@7.5.6': {} + + '@types/unist@2.0.10': {} + + '@types/unist@3.0.2': {} + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3)': + dependencies: + '@eslint-community/regexpp': 4.10.0 + '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.4 + eslint: 8.56.0 + graphemer: 1.4.0 + ignore: 5.3.1 + natural-compare: 1.4.0 + semver: 7.6.0 + ts-api-utils: 1.2.1(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.4 + eslint: 8.56.0 + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@8.56.0)(typescript@5.3.3)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.56.0)(typescript@5.3.3) + debug: 4.3.4 + eslint: 8.56.0 + ts-api-utils: 1.2.1(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.3.3)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.3.4 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.6.0 + ts-api-utils: 1.2.1(typescript@5.3.3) + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@8.56.0)(typescript@5.3.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0) + '@types/json-schema': 7.0.15 + '@types/semver': 7.5.6 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) + eslint: 8.56.0 + semver: 7.6.0 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.2.0': {} + + acorn-jsx@5.3.2(acorn@8.11.3): + dependencies: + acorn: 8.11.3 + + acorn@8.11.3: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + array-union@2.1.0: {} + + autoprefixer@10.4.17(postcss@8.4.35): + dependencies: + browserslist: 4.22.3 + caniuse-lite: 1.0.30001668 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.0.0 + postcss: 8.4.35 + postcss-value-parser: 4.2.0 + + axobject-query@4.0.0: + dependencies: + dequal: 2.0.3 + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + before-after-hook@3.0.2: {} + + binary-extensions@2.2.0: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.2: + dependencies: + fill-range: 7.0.1 + + browserslist@4.22.3: + dependencies: + caniuse-lite: 1.0.30001668 + electron-to-chromium: 1.4.661 + node-releases: 2.0.14 + update-browserslist-db: 1.0.13(browserslist@4.22.3) + + buffer-crc32@0.2.13: {} + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001668: {} + + ccount@2.0.1: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + character-entities@2.0.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + code-red@1.0.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + '@types/estree': 1.0.5 + acorn: 8.11.3 + estree-walker: 3.0.3 + periscopic: 3.1.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + cookie@0.6.0: {} + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.0.2 + + cssesc@3.0.0: {} + + date-fns@3.3.1: {} + + debug@4.3.4: + dependencies: + ms: 2.1.2 + + decode-named-character-reference@1.0.2: + dependencies: + character-entities: 2.0.2 + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + dequal@2.0.3: {} + + detect-indent@6.1.0: {} + + devalue@4.3.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + didyoumean@1.2.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dlv@1.1.3: {} + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.4.661: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + es6-promise@3.3.1: {} + + esbuild@0.19.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + + escalade@3.1.2: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-compat-utils@0.1.2(eslint@8.56.0): + dependencies: + eslint: 8.56.0 + + eslint-config-prettier@9.1.0(eslint@8.56.0): + dependencies: + eslint: 8.56.0 + + eslint-plugin-svelte@2.35.1(eslint@8.56.0)(svelte@4.2.10): + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0) + '@jridgewell/sourcemap-codec': 1.4.15 + debug: 4.3.4 + eslint: 8.56.0 + eslint-compat-utils: 0.1.2(eslint@8.56.0) + esutils: 2.0.3 + known-css-properties: 0.29.0 + postcss: 8.4.35 + postcss-load-config: 3.1.4(postcss@8.4.35) + postcss-safe-parser: 6.0.0(postcss@8.4.35) + postcss-selector-parser: 6.0.15 + semver: 7.6.0 + svelte-eslint-parser: 0.33.1(svelte@4.2.10) + optionalDependencies: + svelte: 4.2.10 + transitivePeerDependencies: + - supports-color + - ts-node + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.56.0: + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.56.0) + '@eslint-community/regexpp': 4.10.0 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.56.0 + '@humanwhocodes/config-array': 0.11.14 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.1 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.3 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + esm-env@1.0.0: {} + + espree@9.6.1: + dependencies: + acorn: 8.11.3 + acorn-jsx: 5.3.2(acorn@8.11.3) + eslint-visitor-keys: 3.4.3 + + esquery@1.5.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.5 + + esutils@2.0.3: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.0.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.2.9 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.2.9: {} + + foreground-child@3.1.1: + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + + fraction.js@4.3.7: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + github-slugger@2.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.3.10: + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.3 + minipass: 7.0.4 + path-scurry: 1.10.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalyzer@0.1.0: {} + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.1 + merge2: 1.4.1 + slash: 3.0.0 + + globrex@0.1.2: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + hasown@2.0.0: + dependencies: + function-bind: 1.1.2 + + hast-util-heading-rank@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-string@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + highlight.js@11.9.0: {} + + ignore@5.3.1: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.0.0: {} + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-absolute-url@4.0.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.2.0 + + is-buffer@2.0.5: {} + + is-core-module@2.13.1: + dependencies: + hasown: 2.0.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-obj@4.1.0: {} + + is-reference@3.0.2: + dependencies: + '@types/estree': 1.0.5 + + isexe@2.0.0: {} + + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + just-camel-case@4.0.2: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + known-css-properties@0.29.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@2.1.0: {} + + lilconfig@3.0.0: {} + + lines-and-columns@1.2.4: {} + + locate-character@3.0.0: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.castarray@4.4.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.merge@4.6.2: {} + + longest-streak@3.1.0: {} + + lru-cache@10.2.0: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lucide-svelte@0.323.0(svelte@4.2.10): + dependencies: + svelte: 4.2.10 + + magic-string@0.30.7: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + + markdown-table@3.0.3: {} + + marked@5.1.2: {} + + mdast-util-definitions@5.1.2: + dependencies: + '@types/mdast': 3.0.15 + '@types/unist': 2.0.10 + unist-util-visit: 4.1.2 + + mdast-util-find-and-replace@3.0.1: + dependencies: + '@types/mdast': 4.0.3 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + mdast-util-from-markdown@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + '@types/unist': 3.0.2 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-decode-string: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.1 + micromark-util-character: 2.1.0 + + mdast-util-gfm-footnote@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-from-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + devlop: 1.1.0 + markdown-table: 3.0.3 + mdast-util-from-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.3 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.0 + mdast-util-gfm-autolink-literal: 2.0.0 + mdast-util-gfm-footnote: 2.0.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.3 + unist-util-is: 6.0.0 + + mdast-util-to-markdown@2.1.0: + dependencies: + '@types/mdast': 4.0.3 + '@types/unist': 3.0.2 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-decode-string: 2.0.0 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.3 + + mdn-data@2.0.30: {} + + mdsvex-relative-images@1.0.3: + dependencies: + just-camel-case: 4.0.2 + unist-util-visit: 3.1.0 + + mdsvex@0.11.0(svelte@4.2.10): + dependencies: + '@types/unist': 2.0.10 + prism-svelte: 0.4.7 + prismjs: 1.29.0 + svelte: 4.2.10 + vfile-message: 2.0.4 + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.0: + dependencies: + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-factory-destination: 2.0.0 + micromark-factory-label: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-factory-title: 2.0.0 + micromark-factory-whitespace: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-html-tag-name: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-subtokenize: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-autolink-literal@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-footnote@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-strikethrough@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-table@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-gfm-task-list-item@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.0.0 + micromark-extension-gfm-footnote: 2.0.0 + micromark-extension-gfm-strikethrough: 2.0.0 + micromark-extension-gfm-table: 2.0.0 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.0.1 + micromark-util-combine-extensions: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-destination@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-label@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-space@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-types: 2.0.0 + + micromark-factory-title@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-whitespace@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-character@2.1.0: + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-chunked@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-classify-character@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-combine-extensions@2.0.0: + dependencies: + micromark-util-chunked: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-decode-numeric-character-reference@2.0.1: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-decode-string@2.0.0: + dependencies: + decode-named-character-reference: 1.0.2 + micromark-util-character: 2.1.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-symbol: 2.0.0 + + micromark-util-encode@2.0.0: {} + + micromark-util-html-tag-name@2.0.0: {} + + micromark-util-normalize-identifier@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-resolve-all@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-util-sanitize-uri@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-encode: 2.0.0 + micromark-util-symbol: 2.0.0 + + micromark-util-subtokenize@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-symbol@2.0.0: {} + + micromark-util-types@2.0.0: {} + + micromark@4.0.0: + dependencies: + '@types/debug': 4.1.12 + debug: 4.3.4 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-encode: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-subtokenize: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.5: + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + + min-indent@1.0.1: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.1 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: {} + + minipass@7.0.4: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mri@1.2.0: {} + + mrmime@2.0.0: {} + + ms@2.1.2: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.7: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.14: {} + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.3: + dependencies: + '@aashutoshrathi/word-wrap': 1.2.6 + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.10.1: + dependencies: + lru-cache: 10.2.0 + minipass: 7.0.4 + + path-type@4.0.0: {} + + periscopic@3.1.0: + dependencies: + '@types/estree': 1.0.5 + estree-walker: 3.0.3 + is-reference: 3.0.2 + + picocolors@1.0.0: {} + + picomatch@2.3.1: {} + + pify@2.3.0: {} + + pirates@4.0.6: {} + + playwright-core@1.41.2: {} + + playwright@1.41.2: + dependencies: + playwright-core: 1.41.2 + optionalDependencies: + fsevents: 2.3.2 + + postcss-import@15.1.0(postcss@8.4.35): + dependencies: + postcss: 8.4.35 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + + postcss-js@4.0.1(postcss@8.4.35): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.35 + + postcss-load-config@3.1.4(postcss@8.4.35): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.4.35 + + postcss-load-config@4.0.2(postcss@8.4.35): + dependencies: + lilconfig: 3.0.0 + yaml: 2.3.4 + optionalDependencies: + postcss: 8.4.35 + + postcss-nested@6.0.1(postcss@8.4.35): + dependencies: + postcss: 8.4.35 + postcss-selector-parser: 6.0.15 + + postcss-safe-parser@6.0.0(postcss@8.4.35): + dependencies: + postcss: 8.4.35 + + postcss-scss@4.0.9(postcss@8.4.35): + dependencies: + postcss: 8.4.35 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.0.15: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.35: + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.0.2 + + prelude-ls@1.2.1: {} + + prettier-plugin-svelte@3.1.2(prettier@3.2.5)(svelte@4.2.10): + dependencies: + prettier: 3.2.5 + svelte: 4.2.10 + + prettier@3.2.5: {} + + prism-svelte@0.4.7: {} + + prismjs@1.29.0: {} + + punycode@2.3.1: {} + + purgecss@6.0.0-alpha.0: + dependencies: + commander: 10.0.1 + glob: 8.1.0 + postcss: 8.4.35 + postcss-selector-parser: 6.0.15 + + queue-microtask@1.2.3: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + reading-time@1.5.0: {} + + regexparam@3.0.0: {} + + rehype-autolink-headings@7.1.0: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.2.0 + hast-util-heading-rank: 3.0.0 + hast-util-is-element: 3.0.0 + unified: 11.0.4 + unist-util-visit: 5.0.0 + + rehype-slug@6.0.0: + dependencies: + '@types/hast': 3.0.4 + github-slugger: 2.0.0 + hast-util-heading-rank: 3.0.0 + hast-util-to-string: 3.0.0 + unist-util-visit: 5.0.0 + + remark-container@0.1.2: {} + + remark-external-links@9.0.1: + dependencies: + '@types/hast': 2.3.10 + '@types/mdast': 3.0.15 + extend: 3.0.2 + is-absolute-url: 4.0.1 + mdast-util-definitions: 5.1.2 + space-separated-tokens: 2.0.2 + unified: 10.1.2 + unist-util-visit: 4.1.2 + + remark-gfm@4.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-gfm: 3.0.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.4 + transitivePeerDependencies: + - supports-color + + remark-github@12.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-find-and-replace: 3.0.1 + mdast-util-to-string: 4.0.0 + to-vfile: 8.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.1 + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-from-markdown: 2.0.0 + micromark-util-types: 2.0.0 + unified: 11.0.4 + transitivePeerDependencies: + - supports-color + + remark-reading-time@1.0.1: + dependencies: + reading-time: 1.5.0 + unist-util-visit: 3.1.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.3 + mdast-util-to-markdown: 2.1.0 + unified: 11.0.4 + + resolve-from@4.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.0.4: {} + + rimraf@2.7.1: + dependencies: + glob: 7.2.3 + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.9.6: + dependencies: + '@types/estree': 1.0.5 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.9.6 + '@rollup/rollup-android-arm64': 4.9.6 + '@rollup/rollup-darwin-arm64': 4.9.6 + '@rollup/rollup-darwin-x64': 4.9.6 + '@rollup/rollup-linux-arm-gnueabihf': 4.9.6 + '@rollup/rollup-linux-arm64-gnu': 4.9.6 + '@rollup/rollup-linux-arm64-musl': 4.9.6 + '@rollup/rollup-linux-riscv64-gnu': 4.9.6 + '@rollup/rollup-linux-x64-gnu': 4.9.6 + '@rollup/rollup-linux-x64-musl': 4.9.6 + '@rollup/rollup-win32-arm64-msvc': 4.9.6 + '@rollup/rollup-win32-ia32-msvc': 4.9.6 + '@rollup/rollup-win32-x64-msvc': 4.9.6 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + sander@0.5.1: + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.11 + mkdirp: 0.5.6 + rimraf: 2.7.1 + + semver@7.6.0: + dependencies: + lru-cache: 6.0.0 + + set-cookie-parser@2.6.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + sirv@2.0.4: + dependencies: + '@polka/url': 1.0.0-next.24 + mrmime: 2.0.0 + totalist: 3.0.1 + + slash@3.0.0: {} + + sorcery@0.11.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + buffer-crc32: 0.2.13 + minimist: 1.2.8 + sander: 0.5.1 + + source-map-js@1.0.2: {} + + space-separated-tokens@2.0.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.0.1 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + commander: 4.1.1 + glob: 10.3.10 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svelte-check@3.6.3(postcss-load-config@4.0.2(postcss@8.4.35))(postcss@8.4.35)(svelte@4.2.10): + dependencies: + '@jridgewell/trace-mapping': 0.3.22 + chokidar: 3.6.0 + fast-glob: 3.3.2 + import-fresh: 3.3.0 + picocolors: 1.0.0 + sade: 1.8.1 + svelte: 4.2.10 + svelte-preprocess: 5.1.3(postcss-load-config@4.0.2(postcss@8.4.35))(postcss@8.4.35)(svelte@4.2.10)(typescript@5.3.3) + typescript: 5.3.3 + transitivePeerDependencies: + - '@babel/core' + - coffeescript + - less + - postcss + - postcss-load-config + - pug + - sass + - stylus + - sugarss + + svelte-eslint-parser@0.33.1(svelte@4.2.10): + dependencies: + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + postcss: 8.4.35 + postcss-scss: 4.0.9(postcss@8.4.35) + optionalDependencies: + svelte: 4.2.10 + + svelte-fa@4.0.2(svelte@4.2.10): + dependencies: + svelte: 4.2.10 + + svelte-hmr@0.15.3(svelte@4.2.10): + dependencies: + svelte: 4.2.10 + + svelte-markdown@0.4.1(svelte@4.2.10): + dependencies: + '@types/marked': 5.0.2 + marked: 5.1.2 + svelte: 4.2.10 + + svelte-preprocess@5.1.3(postcss-load-config@4.0.2(postcss@8.4.35))(postcss@8.4.35)(svelte@4.2.10)(typescript@5.3.3): + dependencies: + '@types/pug': 2.0.10 + detect-indent: 6.1.0 + magic-string: 0.30.7 + sorcery: 0.11.0 + strip-indent: 3.0.0 + svelte: 4.2.10 + optionalDependencies: + postcss: 8.4.35 + postcss-load-config: 4.0.2(postcss@8.4.35) + typescript: 5.3.3 + + svelte@4.2.10: + dependencies: + '@ampproject/remapping': 2.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.22 + '@types/estree': 1.0.5 + acorn: 8.11.3 + aria-query: 5.3.0 + axobject-query: 4.0.0 + code-red: 1.0.4 + css-tree: 2.3.1 + estree-walker: 3.0.3 + is-reference: 3.0.2 + locate-character: 3.0.0 + magic-string: 0.30.7 + periscopic: 3.1.0 + + tailwindcss@3.4.1: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.0 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.35 + postcss-import: 15.1.0(postcss@8.4.35) + postcss-js: 4.0.1(postcss@8.4.35) + postcss-load-config: 4.0.2(postcss@8.4.35) + postcss-nested: 6.0.1(postcss@8.4.35) + postcss-selector-parser: 6.0.15 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tiny-glob@0.2.9: + dependencies: + globalyzer: 0.1.0 + globrex: 0.1.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + to-vfile@8.0.0: + dependencies: + vfile: 6.0.1 + + totalist@3.0.1: {} + + trough@2.2.0: {} + + ts-api-utils@1.2.1(typescript@5.3.3): + dependencies: + typescript: 5.3.3 + + ts-interface-checker@0.1.13: {} + + tslib@2.6.2: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typescript@5.3.3: {} + + undici-types@5.26.5: {} + + unified@10.1.2: + dependencies: + '@types/unist': 2.0.10 + bail: 2.0.2 + extend: 3.0.2 + is-buffer: 2.0.5 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 5.3.7 + + unified@11.0.4: + dependencies: + '@types/unist': 3.0.2 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.1 + + unist-util-is@5.2.1: + dependencies: + '@types/unist': 2.0.10 + + unist-util-is@6.0.0: + dependencies: + '@types/unist': 3.0.2 + + unist-util-stringify-position@2.0.3: + dependencies: + '@types/unist': 2.0.10 + + unist-util-stringify-position@3.0.3: + dependencies: + '@types/unist': 2.0.10 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.2 + + unist-util-visit-parents@4.1.1: + dependencies: + '@types/unist': 2.0.10 + unist-util-is: 5.2.1 + + unist-util-visit-parents@5.1.3: + dependencies: + '@types/unist': 2.0.10 + unist-util-is: 5.2.1 + + unist-util-visit-parents@6.0.1: + dependencies: + '@types/unist': 3.0.2 + unist-util-is: 6.0.0 + + unist-util-visit@3.1.0: + dependencies: + '@types/unist': 2.0.10 + unist-util-is: 5.2.1 + unist-util-visit-parents: 4.1.1 + + unist-util-visit@4.1.2: + dependencies: + '@types/unist': 2.0.10 + unist-util-is: 5.2.1 + unist-util-visit-parents: 5.1.3 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.2 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + universal-user-agent@7.0.2: {} + + update-browserslist-db@1.0.13(browserslist@4.22.3): + dependencies: + browserslist: 4.22.3 + escalade: 3.1.2 + picocolors: 1.0.0 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vfile-message@2.0.4: + dependencies: + '@types/unist': 2.0.10 + unist-util-stringify-position: 2.0.3 + + vfile-message@3.1.4: + dependencies: + '@types/unist': 2.0.10 + unist-util-stringify-position: 3.0.3 + + vfile-message@4.0.2: + dependencies: + '@types/unist': 3.0.2 + unist-util-stringify-position: 4.0.0 + + vfile@5.3.7: + dependencies: + '@types/unist': 2.0.10 + is-buffer: 2.0.5 + unist-util-stringify-position: 3.0.3 + vfile-message: 3.1.4 + + vfile@6.0.1: + dependencies: + '@types/unist': 3.0.2 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.2 + + vite-plugin-tailwind-purgecss@0.2.0(vite@5.1.0(@types/node@20.11.16)): + dependencies: + estree-walker: 3.0.3 + purgecss: 6.0.0-alpha.0 + vite: 5.1.0(@types/node@20.11.16) + + vite@5.1.0(@types/node@20.11.16): + dependencies: + esbuild: 0.19.12 + postcss: 8.4.35 + rollup: 4.9.6 + optionalDependencies: + '@types/node': 20.11.16 + fsevents: 2.3.3 + + vitefu@0.2.5(vite@5.1.0(@types/node@20.11.16)): + optionalDependencies: + vite: 5.1.0(@types/node@20.11.16) + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + worktop@0.8.0-next.18: + dependencies: + mrmime: 2.0.0 + regexparam: 3.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + yallist@4.0.0: {} + + yaml@1.10.2: {} + + yaml@2.3.4: {} + + yocto-queue@0.1.0: {} + + zwitch@2.0.4: {} diff --git a/website/src/routes/downloads/older/+page.svelte b/website/src/routes/downloads/older/+page.svelte index 9f535230..346afbce 100644 --- a/website/src/routes/downloads/older/+page.svelte +++ b/website/src/routes/downloads/older/+page.svelte @@ -102,7 +102,7 @@

^%hnvw3ZiVR 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 PR<^)^&84UJ=h6QF)vtX~ literal 0 HcmV?d00001 diff --git a/metadata/tr/short_description.txt b/metadata/tr/short_description.txt new file mode 100644 index 00000000..2a0d24cd --- /dev/null +++ b/metadata/tr/short_description.txt @@ -0,0 +1 @@ +Spotify Premium gerektirmeyen hafif ve kaynak dostu spotify istemcisi \ No newline at end of file diff --git a/metadata/tr/title.txt b/metadata/tr/title.txt new file mode 100644 index 00000000..0271be7e --- /dev/null +++ b/metadata/tr/title.txt @@ -0,0 +1 @@ +Spotube \ No newline at end of file From 2abb5800be804a0ff42851e6dcab9e25dbb60f1c Mon Sep 17 00:00:00 2001 From: Karim <37943746+ksaadDE@users.noreply.github.com> Date: Tue, 9 Apr 2024 18:53:09 +0200 Subject: [PATCH 036/261] docs: remove appimage link in readme #1082 (#1171) * Updating Readme according to #1082 Updating Readme according to #1082 * Added explanation The explanation is now given and the expression is more formal and explanatory, instead of just linking the issue. --- README.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index 4ad4e1be..457a86de 100644 --- a/README.md +++ b/README.md @@ -97,12 +97,7 @@ This handy table lists all the methods you can use to install Spotube: AppImage - - - Download AppImage - -

P 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%0Ddz*i32IG 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℞4=C&JSCO%97@ajwQju8;X#%(G zNL)Xd3&G=GT9)m{&1-$yF@S5m{LVRc(mQLg0w(QTEtX1!imq2|&bZ*5vW)zeKCg9+ zo)W+U37x6}G8Di&`wm1Ilxp{xqkgi>hI>+RybK&KW_;01UAH#TF{$DOS*NRs71n{fU=O@r9bPhND5giQylA#OsOgtlM8aRyBafieLY= z(#X(mpiCzbtBG(_+66Wn4t9SGrWgdCAJ<0yHg$DY8(dwGr~`_|OWpxFtqEioI3}lO zzYVtD@J#CV)2+ClHO<2BZ+{e!+`eEGxLAe}YDaiyDc_0F@i9-z9QCsbjBPb!x9ln@ ziH0HON~+Q-$Hgjn@R%vZGY=NMqc(!*wK`W#k#nVYE9AjKJ4Oi%N)IYNA6aVxHby|> z;~$T76rlSFHh;f60@%;4#(fg~y>%=g(|z)c>8T55+t}%*6Ad@nU_V-^wbliwCj+G8 z2tGzph4zl9Kp6d4{t_@CO!n3Xvyuijq#Jq)&Nv* zyQP7e83#!vf+aHVa+?GdK8mXRqL8I#9FJNq&f1&O^Dx3W3KjQ%@k8S6I=5qZ7FNBa z&HZFVB4@WQ*Rh&`<>MGFstQ(;BV@t6*ze`r%PT&hAzcYYB~euF1N@SZqhq>G^{ z=JFLQ=hW4EFPq?0#_*?5is>+W3GWGMj%Azs zQAJx2;#XGM_Kz%9&JSOhLrLc|YUkrqZP(dLYH4y{_xlAIcl+=0sjpUI$_FB?qWZDJ zLw{oC$93L{;LdFMD4D%GrvS=+=$QfFUVr7)FD-#bp0%U*fzfsJsav4H1GN2XfQ^O3 zyMJCvK<$x(`u`Mc331Dp#`f(p6DA1T?W!$p8v>i6NuuXBG4Z<$qa#@6@+!E%%>!qf za^G3<7+4$b8d2rm!;#shnbDDD8K~o?W2*bef-A<|U~BoxPZ_7F0yNw~Vajaj9?hN1 zjWRc0UoaaM8|KL?p>Wwd?JG}s(8A<1`O}^-*0Vuyi-X<&?W0vAH>fw~m&e?M06N0y zjD5(7mabyr=Pl&YK4}&xr^=hmq3Lsb5y=z59i0~NA%RCz;{v6u@v;=x6*Jdmd$c5K zJ%G#WP?-e~^X>utO>J8MZZEvw_}jVpaYV7x)`rKmv=_#6t4o`u6!2{`cr@d zu|x2wQe3>BIc^w+0p`I8r+pVM|M|Y-Yz$F-Hukbh(zc%4itYVuEVX(cdq+H`3V$W# z5Bvx_o%(eba=jg>8!rQ#DsO+XA(}wr=^O(eIihl|r>VT?nc)%M7f=pBJ@IC+LzF%| z=LKd+YZQPH{Fhrq@I~Z*=xF5L-hD!K)7-9+(rp}(J>7XWnU5~X^~RGgcme3QV3XF| zhH^Io<@b5h%cu2^>+Niyyv^-c(_QT&|_E^PRuIT^Ouke?I_6+hX?eO*=d z9rE)@Jyd1C>I6nhSzxMIE?CG`;VBraPLq|O+{hu*$Y+*P= zS$Lv4gd|3py72y8-8v&Uld2FE7{c_rD}gem9;VW=LXr%}`2j)RO&!Kl*L1;+u90`b zu`l4&AR8ZfO$%=@A&){i*2uOaLS&h2lox7vieif3%0$Y(#P~^OcdO5cm8s;DTba5| z9qImnAYo{F6`=nE;11BG0!CG|?qqh)YVZ>O&T}N%q~>}q4v4Bl44-n}$K*w{-F44K z7vwA_$8%yUL7l{zNn594iQ^R56&23R1#t`sbZ+hz+PNe#JUHuz#1kcjrY>w9F;2~` z(D);X7acMhu6NYsl*W3GdG@*54V5c43ub#(tEt#vK}ym+LcD7bALL}YPp2u4VT9CYO_mNh0g>?GT;ZdjAU=1c^ zB0CGI4j5k-7cv?OfHwxFEitd}$o$_-m;*DhYoK$;P@j8vNpqTVhhy5pS>?doeA4{- zX*Wz5;M~E4W zovFM6+PhO8^C8AGP;2n5xvZsE^E~NFU7rVEel5> zq~*ffe;r7PGat|ce2BlTSL4>sjs1z4udH*A4%$Ys$--T7&!S(R%T))0F>sZhD+zey z$%8?fYR=PS6s<}3oGKb&NXjAbLnDIcD)f_xc|7l(O^_cy2s=t&G$~b-m}?w+-WTju zDBwXwT{I>-m=qGIy^>hzPgWI3Q9fA@+HPJn(-9+}dzsiy#9qcRvOZVdg&$iOQN64< z$opazc(?-?H~^9d6tN@nKglWNACMTFx1{Iw9*sHlYS@{#g$h^u&j?nTCD6*~jpriA zPyd>9B+VvD5JlxTOoWA0T;L znQ>GpjkF^2g9XcE%S3$*aE#$H`4CTdKQv02yVB2!pg1?lZ74^(lhrd7Csi4{#vG6L*x>wf@z#o}6!5rD! z|BLenKz$?Dk}}s}Mg+mdFV2Ztgs`?@0dzkIarmPG@j{~sLojwBnM!M54Wt=@rXaOG zha8#l)nN;GS9;p{{mZ6J?GBduQfaOD!_y9qRnPBl;wEA=M zk*f(i);L+F@h@jCa^pDE5_8-FlYaWk-aPMOHDS=E@8tEH>u(HXm!8&Mpr^?`Ral9IK57 zPBM_@5eKJ{TIVqqB_}Dn=f_7qtK+KoLlpPiEDUV~gGN{Z&=7`~8j2M%Gp`=-M{=2i zN`wM$M7wI+{dWH10I5g65>o@fC4fc%W7pww+#PY1FTHzt@9FYb-q?YS(NNX85qDzf z)OZ}!n)U;vhPSW?LJ^i|mvbrz(us2`M`602X>35)-w*2ze4XdYoJ5?koZ9Msz_>fc zi-TF1ibV&m+3eCIvUZI1;x{i;eU^vfnV=n4wndCjvU|!w zo8^#WP<1g=MArZTf1oL@npK@mD&(;hXS28 z;`h&TdMi1SQ$v~zRoRq^jtSzl_IQczRbe~TBkJQT%-h}ZTjhEtnNXcf-|K&f6DAuD zTURbu^v69?Nrxopq%;|EoB6&{ByTq9hRJ6fb)N!(r3@z;VTMjLfws`8vaZ5M%%`{Q zI+>dvZL0J6p)VJ%YyM7_n4FZ`*8fG9x_6-Cyx$cvBeY<-ljcXB{>m&v=~{2SF({a= zKP1x|@heSkfQcyz`zkF+IPnr`Wpx)5&+#oQ**bi-buo!(aIgy+XEd$(yE3543p^t% z>)EH!w|tcR1&RDDdrxWM*{S54IYLNjzg_;vq6#9)^C_PE)_TLnyf(?kXUO~z=*;*J z3P)9^AV^t^@8YI?_CoR`_OR!vMs?6rEae{3qfX$o1`5ysyCw*z5(=LxmfL_Fh&71I ztkYA&*PpU1D)-&Jnb=J^%IC)(htq~9#RpTodM5tQMk7q5;IUw}J&E}6sbYy^YF{C7 zi=c0=iJxmjXpEr|NdgSgm)~BWChFQUS!twfL)KfM$D_!MPd=1#t;vC#$S0vLFk);K zjfvZPCxM3ineIK|WgV5YPs;-xq~+YkNl)(w}W>{VQS==pwuBbZX7w zHAV_rEyB3Yls6LPEtqNIdu@|c3J$vCusclQWV>}AYpGwd4;wbN2kis$MYaUdu zbb)ayLy0J>pOR1c#>4JBbBbwl!}KLqeGx-J#;aoe3;}{WL)xNr#7J10f+U|(nKKbF z)5Rwynd!wlTf8G5-HPJH=GE!+Vt))frh8AdLxP{T9p!`9dV}sC=WRh`8X^$(NW!K`(^~94}y^IuAS=NykaEGPiWP0D-A{WfnhcY^n~vx&TT# zKv}nv-~7#+TAFr3E#+CmwOjE7JE0dmb-BM$4`PzlYF5a9_?S+mQG+oCi4hAyxMZ&8 zY%dZAf+!r-m^q!Bq@o*EdSV9boFQDI$Z3^(_{c-URq`lu5-dV)|732e(3&>G8f~Qy zz1quZI&Y83Am^Gtq&ugS2v;bvI~YA_5loi-uMCBKNK9ubjpx}^${@+!_pa6d)~A3Z z0lm}E#Sd9Yk?o`F{4)_p4|$-Bo|?1c>}vxrYg1 zJ2i7mJ<5Qfc5FO5_LO^|xpg3Ju&5MDK65cfm`shQ1$94xjC+StRufBmnw7RbDJmMF zt>gGXym(0B>AukHu&9wC$w&n;wi+h0b5#H2>ngi6b}Bc0p&m{*DI8+==+YzjX`h1= z08u9g)Ia}fu6+~q(EI~hSYWg6#4>=x%^>f3FI2SQ{v!Wi732KaK;t4i0pI#`GHm79 z+l$jU`#h<44q-|DCvi1SQo_{kT};nxaUHGUx3sY&^YCCRtjnZvO^-oeTlFU)yfz%d z?wQ)jZy`>&5Y^cxXLfeAgbLx12eh7uXkFszx6BVr9_UsLu2-8WS=)Px`GHu}x2U*5 zKUwDaE!_MaHn-*uzA2xy;m;Jg6D zbR*TM3wyl%ziQai1Rd=#z1;{dUE{FbMp-M3?+eUXdH$95Y~pY?t-3?&vn7dFVxYRG z&TU3+LW%n&v$^JC;Xal{y!8Fae7Wpbe5%bVc%oTRmSWMuvR*0*9@}1}Y^;(yBFIeh z?2Uj76<+L<9RDREB|I(xM<{&CR_Ifix2QDv@K=$fP$m@&DxykVLg5f)L8defsWd2U zkg5xIfo{f0BJC6wn*>0z9jFBs>V zsdMcazqF0rS+>lR@GWNZzF!*~^vr^9Ob~Ai=dEcPpvJiR|H&=P?sz-Wl2fxd8-}P3(U1SGD zmY<(Kf~?ur6R%Qb(+v`uI+*S&=t@;LH8~SPMF!uVVyo>`EdHWZneo8#=29nQVWA=t zwlX&%oV3%;ZId0>o%(Xi1vFfDp9B5re@WgRpqmaEvhX(8Bn8Zt9!T3I9n-9Jce=dV z`d{zG-Q~X&YE}40Q8xWlyK-BW!2DfTtcgaDqr>+kp`4b==>hMIkFr{2 z@;F=B3h*TFa9Lq!%odN_YY+CpH<2lzoTx&0>A*D(oy`}TN5`4X*9Vuok`H9F$Q6e^ zYX>!sL%Q$iel%@QPQ}ApdJ8HDSl~SV()ieD-lW(h-!VqoO zfGNUJ>-mDLz@7ZHFHS|&J(OQPiHqD_MCB0nsH~$>uWnD;K%(t3We`cF*81lzm3AZY z`Zl^MAA%Zn7SpR50}8=(XAeF@rkUC-kfKt&Y4QQl=PATB)YZGX2oulJEp^;*s;({MdpiMoZCCst3`L3lXVIX7A_G%Z8b z*{U@(7OQ}ai`&eP5@a7u;nXwCs;%$lVW+~gH7g|RI61ZN9ReS*oc?Xk&h6$YDZXjw@IR`*i$uRHRf-v4-ZveeYkau-*z(-fdCV;$wXq=W0TohY=Zl#~VSOVaN`j@oEP(Gsojet}Um!I1 zM&!+-!ojxZ!xWLBw3SgVYvlq(aYdd#F^r5E;+>_RP%t%OW2*I)n^55k^KE^=6OJRB zUcJ`61kUQe*c7zeUDp0-MC~FE8CeTc_DRPh2fFyi1|O^36KAmw2Xv;9J<)FoWE!cK z`YVQC(1?MLByXS7uG~>1tu-G!zB#EpvUyK{71itU%#v4QV}YmUn(M=>8LN1h&^889 z-|VWO67wM6!lQ3Nh@U!Ohlg{biNi#THWWfwaXjo)5N;6p5Kt4J*zNqSLglpNtrCCC zpNUo5C$MR6NL>F4XC8AA+l;8Q3rXm5pHdXLzZt82MFERzwVReLaPlT|&ge9a`Q)-J z(T1E}S7&3weRxbW2XunbFzGB-VE#@OUVWkiNqhq`k4~)8=R>E{8wzjR^@;#-<`}z- zaeF?Xduh^%Mh%$`&8EJURmeO-Y>!_0)r)0Ru%7Z=WUb4FvUK$RyyHGdDBKf7uxeMc zh!Ir6A^eO=Z1`>t=A9M=Hyuysg29Rdo!WL;>JS5!R2uf+XZYL$DMgS`+QR4kP&%pw zkX$G$&L%IX$BP^uIvm#Cf>f8f!5o$mh1H@6-M548PAwdUDn0gvaUQuOiQ+!SbzX!m z$1rXjhCiT9LDN$Vl6C5tc zs1Hb}JE{|Ap;M4&;=E?s)DPq3X+0m4*0dAhIxK|{Et}4BsQCo?dEq#hss+ z*QhZ-XjH$gMrL|W-swb?$>f;V`^(1tQ=Qy3{dz-I@#qEkX zPZm##pJ-p8X*9WIdu|T|4J?8Bz_ZB(@LYItY52k@IQlJM%+@8qzvSAZHQ(E*IqtpI znA6OWkU%s_gJ$4Vp&2+PxEi8tK;H4o!$h)hL#?L%(V7ywjVi8y1JWwV!_2)}yPPjd z&Fn&m$P}T*H~dg&{HLfm1uL9o+ttfOI-SYa9|Pf$T|I{^#%{=Kd0_nZhdHcbJNhb{ps!VwP}J27n8Ie2+kJ%ENoVSKCJH-jGzYK+nTGq#9W!9tyw$iVF;a$7NNh7%F$>MAp|ST#cw%o!D}eZ$qW6n7Y3sFTtBl!EykX zLJm+-fWQA{7yG{=)nqsyB8{$du@-u)@0xu@Q%>y0m^wQ(4#@`d|-MCkI)?< zE=w8V=STA=Vc2AJy4f$dx`bS#U`_UWRC;y@3jIt==izqjnBGxd{yVMf+*Y2!X%;U|JXXv{+i|N6fs&r=3!kNPjRwnE){VS+>Ul(|@lw_z6F95)OOk;e5D<53p-zF2 z?%(7jmNkM_j)W%k3ObErw|1mce4MzR7%HYFu-Huu_{Yi92mfp-6hfyHjr&Vy zryHM$jV&vkVuSTKslO<&O!&4GgVk z9%qBqjcek$!B<)_(V?J3E(PcD5ARi!FeIbNiEMK>>2*!$33zs@c{UbB2n!9Q^|O~V zoFSut1jkW2q%O{ce{HRrcg4za^jaG&zLdoRcwV7lE}iobv0L&hxgBpQML5JlFWG^29Q!?J#u7Fz!t143j74Nh!@W4)~fKSzsCol=m`E z?wD!mx0!CljGJW%6Om=vR*T-3HtUWF&eLMXfO2~A2d+Zn)OF1Y#0ea!4J*b!jtZ$+ zxdbb2YyrV&20(K{qashy)hbHm9|PDJG$Rk70stVgjJg{#yUoLn4;PzW z21{`t!^MW=^*QfnEbCL?1K<;p?m3`P@|T{aJ%y^%k`ej%)`wQcKqc}CkRGrby@wNq zyyjA?m{g!`{XNA_4uP6-SFO8=Qo3(WbbF^+b&N==VC{@-Vn-KC=+)1p_V0W#Jc2Lo za2t;!w|7g9oanT|YYkYKBvV?IXaZPxGVe|efDuMU;?6=n`2{xSq)I~(g4fEYwdM*r z=!}!i<=cNtKRM`-^mu-Z5*$M<>AJycbhoCnVXmtgpub_XQVHA*<@t}K6)?2>Dg08? zf{7Y4i>dq_QD83zl-)K}#kbH@SSBz8OM4%OkDK^~WqSqtW0H11Q5?C4hBGIz%40Cw z+~o9NYT>(ZXB<6cXF~7EH?IW^-}U9S&oT7(?vu9}6Xe8~$L^=lhpSl#vuBcdW`hRJ zCdTM~cexM=4(uF)zpK``j7S|}J86Y)>5S#M#OASHXXnl!wtL>`7JexxL(3m<8W^&_@-%MR4J=-rj(-Z9lg_^d!;DD24Vw zTBn{1+ckdG-9pXIT_#qTz1wd$GH$6CeHH<`APIU!+W6~r>vqg;xME!bz3J0@c#fW` zN!QD&p^|dfzqUyW5@_3`c{Fp58nE$!PR#aZWa#sHwT(r^kj#mGh)J@Bp`tb&D0RGe z)|CvCp;zgTwc~t+a*-)!tg&)B$uRXv*W{%;Ww{}gJ(T_ObswwMNpQ)#3CXxB<22dZ z1BbyFH7o~gZYN~K1;p=#biVPp<`Ta>n(58%-wh}*tsb9L)L&SoOHLMI$MM`miZ!_7 z-LZ_#rt5@^o;#waXb51Xp!*8{#V74onAUR#b?>m1tEDxp|A36FW_~ui0O;Y8EOVIa zKWm7W|EwXNLx+67ZlaR_Dk&~;ll=Z6?8go3GzL!#q$!p*?_8RSecUNdR&)3&nx+^(zaU&o*t_v?d#qj$wtpD%?0fb8S2C2bwLubh5a<{yjY$+Z8ClU@?+AfZoxn5Su3YTaDu_mUbWCr z#*bXueq}e|LORKBd2=!eC_ki!b_cFivo1j~sgN`7hZKcw(~xbO-Nq~ z4=V%C<~x>K4%&>98e54|F;s&>X!Hx8cSpeUt@;#j8s;nqFirp)3W(L)FITB&?zS+~ zQ7Ya}riKIYiCn z(sYjdl{?E{_*4r}E<|N}R3ZF@0o<@xpf#zHB%Z0^D*pateG?yMtbTRsu{F_rqvSyR zwKYVOsU=}tvu8P8kd-+94pD!@U(zN z{jLaG!yRLYyw9WJ6$6wHyeElUdZ4RpY=kIV6JF6-*a-^O94@3PN}FI9ZGvNNPvQ0C zf)=BM*7!^CV3$G}jE@T9uLibYD_0F1YMb!8uYPAeAR9Wc`v(OQAj1HGF)*>fz#mKu z5b%KkB7-n73kxYJ8yPw|`uUbew`3h1&zAKMZ2VaP;bDL=FccwwK&Onl8nfXPw@0PF z;G*HrEFyXURrFAUtHFm@ChG^^E;BYC9<-^vlzryIh%@pi(&y6~#4X3|Di>x}{KKcR zM$<>t}^oQT=2(behx%D0aiA6gX!OX{5hXp6X z$W_Bi*O%*mK#@alRviU=5tIH>Tam4n7C0k3G-;Dtx5u|5ipZ~*L;(3GF4v`oxa_cp7S~zS@8$G;an~T;1~!p^?T2@| z;Jg&+UVo*FzK7uR|L)rS_pXnDyS_vG0rk$Nks_yJZxV@PJO%eTM!)+joY>q(Ccfz$ zrP+gz5^30`dkUsaODpz9wq$&lzKvD<^yMa%iP>34><_4UCa%h*?pB`Vwo)}x>6Ybo z73NS2!#&-pxLEu0^ep4I@J#~G>>eD~k@ZR)7zjE-KNQW6$deDd4hgSQ`0|$V?+vg<@yU(f776)$dZZZo2jqe?zi8_5<}}flyZLx8PsC1{@&!B7 z*Zh5Nj@#6o)rbKpBK`q;qU5WC8HWV}WN2ctTtN$&2FfW}{Wl8#fmB|}^Uc^#7m5jy zouj|*gqNbstN>v~HDcdrNC_^VAt*5(Ncz}6 zd6QrnW&BcfUvUQlu3Kzv5BL$Gc|9Iu=6Y@LyErz|WAJ?NhVfad3U;1%ya|&1G>gN{r(Bao^tBl8rA&)H5uFlRAeV|uKfYc$8sB#A419%@@jACNTOc+jwCs`ffX}0 zJW}-WX+a))1XNxDzv#hDNbw&~jpAP4AJBgn_&-UFH2D92bLpY!<@~pMPfuMgBN&%Q zuOh!St7c(^hCQ6OIxUL^r9JnQ%hZI2DO%$lecpL%mq7OV=UC{I?@}4B-%bpfFEX#} zdfC%O6SgEecl+Av&$^5_li+15A<|y>awX0=6Mkq=Y{~cybP`slZa77TX4_w_eKaj8 zy}f)^t}*rp)cUR&w?MOFK-#&F9!h1cxo<(wa6NF>B`xXn-3|92P<f`(thc$??OV zblsmt+RHToj}dLD9P$8iRClX@S*+!~X|I)2ZY;uF)i?e?WX&O9$`B z|A6$5XCh~IskHGXh~;~yZ6kfKCFzPA8Opq$g=ezf)oU#tniwlBJ9^j0Pf!nx70)LC z-KS%Jj$jD(i|l@LQS!Q5aYP|YGVkm20z!{c%cuFfTkAIlr*9TJZ$~H%n1;>FmsB8I zJ{Sx-^EZ{4T9+6jQNIyx}CtWb|h^SHmaecwM%cu4S zG|F*ssnT-OaG3DDNZg@gnE1eV+hJc%aaFV*wEYYJ=i`vrInu>hj2>I*wYBcj#Xu8r z124HRmpmA)30?0lU2cm$DO?{)d}cN{9|6=`iz7q>t{vsyF?RL*2s;K55cFg5-COkG zGQbd4vJVNcMA|Z}V>9ju5Y_G;P0L3gh|{%(|p=;;~U~P?h^Ot+kV&jLe~Y@d2@S>UdpyiocjAdX&Ji|zEQ|H za5<0wfxMs+rCMLF^h*y4nXI=Ta%4Oe{N1S7u(jOR{^T^^7TCTuXK*l5J9U)R@(;*% zw5BQJIN4@fb9Z{>;SF&7fFoBRa!f?p-7}x-W;0$7fIw2>m@sxVm#l_>&sD)x`44mb zx}`(Tjcr>_S=QcQxEKEzBHKVR;o=7@Eoq)l-F~|nAKQ-9P?!yQCpbGca5>5e^xH2L zJ2K7`7bDy6{e5M(^X1+Sz5Y*rUQW0C8^-&jm{-CPN9}(=g{K!$rX-de=e&3mZKb_n z1Xb+etKaX~B5hC_+0S*S-$bfJ9YwY=WOqmEUOjHP^@YG1l~|)*4lGC~`SqTZ;{QtN zl>T&O@H3e+I&y;i*}ZuQgO^^yh9$wG|3+)#ztOsR_p9`P_IAGZ%3Z5-yEjhfi1yQ^ zN~G1I^dFFJe>*(Qw-B%PuKJ%dHy`aOUf6WVN=! z@y++huG_YoO2w7HKOnD}2*%IxUKg*IH@v5;wm+6G2Bk;NGoF4r$um)C^Q{LTj|e&l zUJ4t^w>fi3xeo(0V;sH1CoFY(P1s6XUa27RA`PTmSJ=7+F75z>3Oz5h2Xvs!XF+aO@yrGs04B)rhyOz>1_yLophRFS^cT@>(;OURUV zMRAo00c%u|9DIX`<@aht%2y6JoRiKzA=B8-1ACxDb7Lk4@kZAl1~B? zFJ^?;lvIE6YE}XFApYUQSx`(2<(>nt;%_S(<#4y>=1ah(Zlzaz9K$Ii!0Nso5?s9P zRXfK$9e9L$kdgfAB`tM^>mSgEd$$vk5nf-usV8H@-reL0*5T%lqzqY%1^oMQsa>f? zP`QiTo6Uspe?Zx+gIv~E&^#8)32oHr(4yj`V@&6&!M409cF)S`t@!z436EsducFFpsl0u@X9^1 z^ZMbX;VR~(W&O%(!j2BnC-7@O)u7l7sGmdmh3cWb`$+& zO4=1xgkl=}%cF7mkooiXs(GYv3r^trFX*qsj4bjS$|-^CDxXqq7$26j;?*MS3UDcqc-9{CO5xK1dydEXtDS@et0eoE8J7Jw{L}8gl?rz3I`Nw z@?A#a_Qvz>30!<*N`BmInM=&Et)tKJeR)Tr^nYK~FHM150K0J$s}S|=b(Y2J27e8* zTXGuuX`^qipCbjL_JoFU1kaWJz6`^RT3_-p|267Uz-PWcD0eqb4$7fY}%k1b@?s}fl5J4Q;qpn?9YQst!x_LpANfWO8eZnnQt zikUvy!*e;?htir&mm!>phOMzK;_3#?b0gIPPWsWzUwM^#A5(Rq?;YbbwmFZI|7Xma z&NT}G4IUiPW! zV7YSWTFad`DBDV^FFxIPFP63fKX0Hc=!0gd9#cF$&t&vMFB={E8L!==Tj>8x)cmZ? zEtTO8{@~`nrJ-t_2T)xq(C-_)R(W5uWS3OGiTmH*=P=Uy-~F$>5`o{t^70s=@D;sD zfYJ|L(#X%8uqC5YAgH9{@@dwmLGJ*~zx(gMw{q>7uIoUM-1E{3%jEyHcj>_j-MhzB zk!i|j%b0t>XEpTBbwgb0N2XCq$M_(&H)PYdto zf$9$E|Lh5xM4teN06C!-J&O@6pO2fpwi93NBQ)m%uD!}^Xjh%y0}C>c?Dh_W5%rBa z-kE3DfOHNJYLpBW)UVEWCm(7YQrIKZy9nsQ=wL9{&sOTjY97R}|lMLENzU zr);>{!Qhc&nz_pIu_d{%GGls+=Z3=71b;JO52Ak7Fp|xhKVPs4SYl+Bk~kIfNnSnY zGm_di?@`vXV$c#Vmf-|q+^rJMyN`X-rG+#DU_yR$=i5ud^=s4eMVP2^!)l7K6D4F6 zzKLhgsEx#Rdi81kEBr8w7`fr$>i~OQW2@dzQlaN=cjdAguA+j7<{9Ica3uSGU=h4w zZ7m^5J_@0U1v12;*f0I8)L(I6&Ar`07)r{{e|{Ta}evKifLpZ9Iz&AN?Zm!Jpg~ zzoACfB^U9IGH)>6fni&VJ+8vasN$t@#@OQfT|ve;j)X)SzmQ%+Zg}sBI#uu|KCeV? z#M8hc(J3812}N$xOG3>_+g1nX>QEK49}kuH1v_4(R>u^24y7Uw>Y{&myd0;&RSvJ$ znkw`{a%owzE*msK8 z=G(>z57}vQpUtCh{#a7*b<$1$1YzLcai2?j6`K+S)08QX`5!YQB%Vav;FkS7zR&Ur zG4QI)UQuEFwwn}Hl@fMU<>-d_GXF^QXiqBTF^RPM?GmqNe%|rS#>LUg4~^$Khzv3V zH~SGex~f{2=Jx>^Uq>1bH%(dhs2*dJST*~^^liWe&ePVJ`N!H7#E-Afy=0cpc7bn) z<#|xg=DfNV7kcbm!yi2RJ`OK7jEQk!Q7};Rn`e{BF1;Z zSsmZvZ{Q6+{-w=v8Gm*S3So!|!Dsn2_%@9Pzn4}+`_7kY#Z_W*IUeOUN^g^}b!;5y z1IkDcy~Zn2&!>sCEqVydA-P%JGyIIYz9-d^t(GbRqspu>$(+FJ&;1%hCJC+08C53B zhsH;G1G(5y+4|{TjG`WAguSWSXZND^ZF;1xkq>vS3h>yIyWm^QC1@+V5KLC{Mo|BepyD9*>~#dWe^q&=0aq&nb3~JS!Pmw6qD?R zi)-qIf8og_8;#ueJ)Kbt{T_OPzXP7_t)j5}8~-V;=aJ?Bj|rymwjW2e#y)NrLr) zX7d)kTX^PX_4i-gzTld(jeOGf3QBPdJb&%n_t**T7#EI6vaZ-9nDpZ;m&M?`phFM8 zsqS~G^1HnY%gbCE%oPQBubImk10{It2}wOLF7DwO$VHWU08D!N4MEXP2f+0oS?^RwCK*Mi!=wU5zL6yGe(WyE z_pidsgw(+0vJ=t~eDYx8uYECm7DG53W-rL(lYxdD!O`bycUaH*?A!IRx zen&CpDRvnbnh$v3wWOyEZ+s>S9f}v8p!L5RwTZs-cx;F#2o5ZVBTD(!q)$-3hMl9sS*_=Z zz|f$$_^MqACKNtc0`JiUCIdT&mkMl(;WjJ(aYsLzbpgRyfcZt|>!%tw0+R(s_Zy@w z61Wvi$`|RoF$CR+(9X5%?=H!PCh=Af%4gz^vKP=_p!O^q`6HA)-gT+@ZqJMo1xmRz zELv_z4v#Dey7%FlB;ZLIiGz=neu5O#B}+=h(s;osk%sl;=CBsPpd%gnvwAKa8av5P zLkRsznD5~C*lo8@J0~$GCnxuWH|Fys(!u_NZQd_}3cQVCx7>I(m+~Q)0L&&3r+#^tiq5VnvaTSg@(Rg4sAGO{O(yqIw9{y?hF5)cdUym3hX^ z&ps#{Ad%vo5gmz*MR%bg!aq>D)w(e=K14$T6P{W4LX)7f@Rd))Oxx-zZj3`Z z{)YJK20U7wB$G!L;MdwwkKr;kmF7-9s|=gOKb$N3OYo&&QZD7GzN0=uqp&rE?oUx7 z#Hc$>yGANq!P{b*t6Xd7c1E9pRfbWRK4X$)^R%^c9?64kJesx2T}}E}qY5hrJ`*J9#8GanG> zSTeHgHyzR_Hex4U*LdNy_>Rar1sV^B6>Q#tA;`-;@yYBM1O%su5HSxG>XZyUd`F2~c+S}$G^d(tE2&Ue zM?<7E@JJ=}iv9a3wO^$h&%JWfu5bQSBSS3!6Y?D`8<7r6e@aqIIw0w2s>ebs1yy*o z^gWk-J;lo(z4?YD6%B(ou#xbOx#b9o-~v9mMB*NjdFOrV0qKa%Zd)0cI1Nn#5kN8j zs=9nf^No)TKD8}z!4t2&HJ_b48EWWe%@~G&Pb#2_9pPmH;chpzLbb4xhDq!QFCx4A zQlN`*jexLepzycnoTR}WRgicJRcPIC_~$f%Zy)Z*-%l{f2?^WSdyw4uN` z;QPjPdmm$^f=4Rd6WEXYz8fVj@phUz?_EYM_oW`^;lTLtJbW3W=T$~pr= ztARF~OaXFvG(^sav_|uOLkR-V-$Y?Q^pBl~%lq9d9Xr{TYx%moukXEA==z1~)!ywB z)vDl348Aj1Y#&=bGg0T4A!9kJx8XZV2;CUpe2F+_=y_VdvDeV0gV%tdi3!U$9OZ)g zI==*+KkJ%0P}qwO&G~WuD-|u<7o`?TfteoU7pA_yH{0s<;!H(jfzyFgOy#3Mu z?8eq&z!PIC%(sAMjx-;6sX}TOzb8+?SzSdEb7XL!LB@DRE2j;=@xC)62nBibs zU-k?HnW;%kI*L#*WhbN5&F7`~K{F2y-0i%+3W?qZ@PJILo#O_lcl>$+?jAW1vfVGHs(E^K(;Rpjjb?R5ytaKxHRt5+kDXMzTHtotp;A3tY6oDh+ZTlNRdI_T-+ z)V|Tpe?z$Vj71MRmN*scrB9>VJE?1SYt#f{|B)&sF$#=G^>NOTT-NT3V2w9d_ls-D z@Qj*V$0DP6##lA2pKc4NdUSAdZuWPBNE*C3Lb`Y_3VSp%x08H~dFaR!344FWt?Oh7 zZQGW+cq?Ui67w*Wz=2nab&b)ZxJ$rpA`k6q=Q;m&i|>JIL3tbc2Dv0sczv*JB6Xum zEd_5@r{ocJLYoJ&BC1b9%HzKEX+!K1<$)6rLGU1V2E%-w&k0~9;wBv=++CYK)g%T#<(_>(!OX@n$xvXXDRt zVzK3Yo2ds!NiMHR@8(7`EiWorRQTsT#4Tc9)@tFDcN=7n%7M*ce>thS<@(3GmR(6sKUONY)yR7ZN8@PTn1lP8jR@P}VKeJ^6I-mX@f6fj z%lphvo4z)5$7@a76H$Z;AC~kUZ?2DAW>uui}ArRbI!joKV* zH>?Gz=jg{9oJ5ojMR+bywNf3a1x4Gay1IC-W5yIbGmf(VWMf}-1x4+UL9dvDzRYfw z?yaUwNQvj!3nY=*jKGQFz>oJs3VA)v#fVCy%u^;l<<3k>#FfJL5LM^&dbOHz#9) zULp%d3^s{uk5aWwLZ_l7TqBFUXx*}QXF6~V|CD6MI0AucD< zM@nZ}dfxjyat%s>NJpECNP#q)82h)o&p&cK?PhX>-6XuyU}Gvw$yU-Xxs7F1lX;Dh zx;bDhBx^*Diz_Zof-LFCh3MUkY@dx=k#j!%KIKmZ^^T#fO7}^~B5W~_)Q|hFhp^Y! zptV&GClsRJv|Dd>sv@GhVDTk7HVbiNrEtk!{jqBI%oKCT?gjao$bZuUKm>S5qNLHL zJ~Fx7GMSY>SajZj$)iFF86rOQ=I(yS8AXNBzbdsrtpnZ;`~r8OinjY`C$A{laXP=+ zOfxEsR$qI+Zw;+yEB-2>N|8YBo@pduV>FMweeq1Nx9;4Py>fiJLN!4$!(zo*U=cqV zP!{YBE86_x&qVd1$f44($^K7`2BL>bDCYn!qKj~okI5A4B^*o#TE+ZLQlol}M;NsZ zD+}FR22{{(QZr=gHN^n43NU=)=u8Tyl2L8HCx(**XC{$z3*+S6RT78nVo%T6ls@kq z7;*$3*Ogf|ZFE1av0 zDT#-$hAY0iti>AUCA{g&k}`co-U|#$c6##+Up;dr5HpHGG0g?18nl>K_AW-&>%rL9 zPMnE78F^*m3(?U%rld7g{Ht~q5GNg#EqniS~+(y)o^mQ}U z=pSu1P_L2fQ`Ss^oZpU5i=l#>A~rwpH+^9Z_^lwj)wsVU!tri_?X}wV&V_qQ_iSQo zHx2!2x1fb?_3gh{N8X!$_2^MarW;slZzi#s#b$|VVa(p25qF1s;0h^bHJ)MSQb}>L zleYa9MM>e9)*3~W)Rl*&7Pr|XEHNt^l+cNP(G4aM=fiRIr#2NOZwAuk$C|?rIHD1k z3bUR7P@E18jpOKrJ)>_u>4EAGh0$|UX})i&C8_a#Ti=c>@FI=bQ;z!n#+2OhR0$W` zf&>i~DFUn=jw?VHSY@EQiDbUVg>UF5kwAEa7^OKpdWJ`FrOm8XIDB~RJP}m>Cl_`lCb|Qe z6x-AOJbNXxdH|4poYwKy=y`C;TjDjsr}yPZ3+cfI|7;508HOw}*vk1vt4fEKZ0mnn)0xB)nVBir8j3*riy}Le=nxF=*K@nu zDgZT%?~Z^CGkm(nxsmPMP}Y9eANu`!u&fyNJDq4zP0_+Df)=p`&*-}FCLP~t<6sZc z%z5W6nDV6=)|k{t`-#4OAJQf5kzio>P8`ADVLoHfqI|z?w#mQ>>`Da{f&+JL!kvWz?$Rb8iYYHQPnVTRUnI&BDK(mv;(?_C7r0w>N zg%(5s^P#_wbO+wE8?x9Y8sDdcC$Heci0aZm{L)}aPBJz~aB1r=G&#{GBxbLT9QEv#YePm zmor|(;0p|2d7f-8WE<>`ZbmLg!Dj$T?HCf=wmac3fH}@ zB}yox%%+df=lI>f`p80yI*>^vX1te~*@W<*9}L1B!3NZ*%H9e62d`W-_a#n2^g_9> zqkm<&w=I+jiP)&rT}0KtOK&xn%`AE_yh+UTOvu~1FP5!gR2;8aRcknejU{n8`*=;b z%8kK$#vgDoJ!m}LDzPlH$#{woLNj9WZOwbs^@l9pm=yNGeT`7etROB9%TFI(sjVwn zm`-d}%hNb^Pn9P0YWhi+Bp1FKjXos=EaZdL@j}U3*b9$6G~_Io0Oy%8U2%rAvIktH z0Vzh)J7!@oOUpCqLcpJ_PVKHG1cDLiV1h?yfz!Ij;739&7u+h8;y?A zIIda(phESTniD-c24dw>(+nt7eolA68{WhusMh_dU;Vq+KR{`Wr2;Pm-ul{@E<;6( zkhSlA4gsoUbmIHLJ3hB@Q21-w*F8>>3oF*-w6sP&kAx684`_G#&Y%*QK;`GyE8-BbA$6bnlrF!QcU@|UUtYV|t-Is(8XP(oSg$wk}2%x?f58!MQxa^w}()L3DjO~};;?^j#49fHY zB_dMNXd;3Qoi=vmiPN&)0nEv5Y9e7@h8a&&_yR2H+WR43>IuKU{hHoSfAz`G4S{)s z)RMjtfQ1>;ir8{V;!T9Vz`}^=-n2o;okF@Hzjwq{rWensN;2*^qAIHUb8*Lzx1vp( zA;!FqZDaQ#kHpEmguF)Jhp+j|x#_j+28CaL@0QDs=_L#m_}SzS#`&xTtt#Xo)s$1& z^uo0t5qyPR+Krom{15g-zPJd+B(@i2ezz@NQ~9IkwnhjrUc|kMgKA7Fi|-RS?exo1 z^Qb?~fyaIrY_450^WKDV(pus`Aawx;aUI4Zs^WcJ8>}O{R0WO!ph(jmP4)7f_1Y8G zXY4f$FC0)IjWntg6_OFNKC&xf3-MHgq;n})bZeY`(0e4P{93AKlS+?kMZXwOMe7ru zC6qur3un)p9leuEbCH`2t_dPew8^<$} zo&5rL^j+iW;L=sYGpeb?7UW^YqCL*e4T2`tQ+1E?6a3iZlS2a%(Q-l_jGD@uUYT=4 z`|AY8q$}DnjNt=;QKd|ezUhy&Z?DH?c6~q7{$>$L6H;0vx`!X0Uvju@>kSk@+t`x7 z0!q_F>%d2Oova{>w=Uh*>UjGap2pA8A}$yKdbfKKQ+5@6xr()p4Z7OeYJ0paWz*owocYX#jXWskmD3-9HBjGyK)_zeEITr(wy3|&2Y+h>kK)%J zg;uP&(gdCRQw(Fch8xKdCus~nQ@gDGv5`Mx==znqE6;-X=+kVRgH^KbMeP3=P9 z1n2@O-}u=-vI;iiJp8aN(=RG-U_X4ApYD#>f3Vm9rET0X2OcaZmtqCz(<;=&Zbpv8 zHKywUx_AZ)0g@A>-mcYNzw~HGF^m)z50N*yDz&D6=Rx2YG?s{o|3G@#E~ER!`{yG8 zf61O?bg7k-L|PhWXqBJB%)(ERB|PDM-bciWMWOyqcnU613eqXtF+qR&e}6yG(PM%JK0_2$aZ%s$#hjkzgd zk;Ymnen}p@8zNKdXD03;u?ck1YNRlJ=RU(are`n)#o zGlP1m;-&Q~-v~zr40$Z#e;xlI>2#;y>FgSwCM{;&i^Ne}e>LupD_cbyc^aKn1HEWx zsa|38KE(K3+`bzTBCMn7^>p9M`zYs}mgy*BQI-oqw!50J<(W%rTw63dch$#h$GCRI zW?XJV+7gKvkQQ*tv}GyX@#yv5LGT(c3dQ8FZ&K}79UQz9aq|S>_kCY`H3{K<=gOJf z{{~kJzoNDNIx zIR8@c4S|ZMbioq03rJYbpDrccsjTVD(X+1%L4uZx&1t1Hwg=Yh@jN|togn?fTep_Q z+N~1wj7a|E#>h1bmsfa>*f2XFZn)mXW)AZ zSampkrT6ALXl!i=6LkV<#iX9EYx!r+KS^ndpyNdm23fDX?aZZ`jZ(2EnsBDti~35_ zMZQ*|5t*XEtd3{b=fKDjZdK!bBaSa1Q5rm&RpsJjSZ*x47fvTd*R~s2vYGA(rAIpA z^07^YYoapcsrp}lMO~cDepV?HxP-n4{CZIT)1FT+M{h>EWTS%S9s?U_*!arXAC4+2 z$vo*a_>)(M8DiGSo%u!k)hi#;Hz(K(*%>PTu*zbHX*k7;>)i7TTsoQcd*qCu1 z$Q|k5ranGC$6^s>?luVtU?L9Qz@8*h=DwDcph$74BpN0Nrb?r@@yV-$0v*Vl$CVa) z3R_)VVWXQae&kgEDf{+}!_1R*Mg5tFUuD8C(-VA=qj^HfSTfzEqNo~~(Z+5(Q4R)b z`t@uE0hNGE?seLXyv1yf*1^?6X>0PvrbGSl)Euvx{ZF7=2R!1;f*Kl%xgW@o4Ix(s?QbNtC^ zp^P!{xq7*S1SDGTOLH_H8c(mn)I-K2oavBave!h|{@KrpH4qy0ru`_naFXB+5Xf5h-45Vg|&Rvmy%OoSpSCHg-?5c_sUnOQ- z5+Phj)WV&MzJ^Og_CGT%J6lg&Ai`3 z;$D3-RG_X=dj|h6k^WJU$SJiYtrwOaz)d@D1zF8kH77=Md9jcnJ8MIYO3=UuT90fjJ^x%zPYO7^ch^bS`diff)|+Pq z{nRciUaj2<>N1Ii-bLOg8kHs@n9_MZ+anhRgegUIX24aSM~!sr1LRfg66JF#(qFxe zwf3Vi3KIdU!voSTGq`(Lj)b68k3liNb4$1Kx9T-Sn(yp9V}vT76y+Ol#=t6Coz;vF1JGMR>V)D76dKWH>`VTL|paq?!_JTx>=7rj_+%N z`L!MfM|m`SFP^nS?D#XB%b%s|c^OPtAcL;R@sYBNox!ZwXNS^$-Ie3GgF--7WGwTCSAOY5DU#Vb?E>HvYOe6JG zFI&yQssX!JhwVEl#q=KXGt+RY11;d3Fg+EkN+9`7CZ-&pgbj$FABZB7*`YDDcS)c# zdTilkr4~cjnjG(QVJ}zrzypTaT5E5B_ zCXb$b)+d+g{HpY>IChjo1(+SN%ZJwp4vg5b`~O^Qg?!Xl~!`UicYA1%##xPimK5%wei8yGp2XHo8#HbPw`B75)!Y?H%Q(+GPep zie_d@<%UGGNp=PydUC2Y_3_0nwvR?7hJ8i8t(>Mb32T5?_`=gR&KtUn^@Db=_mO_t zjHBx-&qPWyV)!ziG&Ob;&0QU<9zyyjjk@c$$P0KC%~l6Ohy;mzejX&Y&Jrzz6P5x< z&2tioXp&n^l!%JFe6?heXHl75KjnfCf>Q!hpYG%Q#czfVERm*iw>pAeGtDrH2Q~vo zgC*n<^pH3e$zfH%0{cVuD(d8~K`1C|yG6leiv8)&mQv2e)1tW1{`7R|JVM&SlVo4s zcX;7JYIDD1CMr0yEtQ5RVVPM1r5>9LL;+s*u`CN0>@>LEZkKQ)#~*z2rETktr^c_c z%}q~x>WRzvgROJg_5c(B&dm`&qO?5OCWyZ_yeq$Ov6iZoI}iUvlTv9W;XwF1y)uHT zK^*immFvKuaI(xOMBVFV;yL0{47ssC_p(d9O;fQGa5=xC^r9{JKx)zuaifWgl^6$E zAmw^Mk%?}{kI1}V$^iqAwl2DuVg`x35EV1kn_yXtL)cMQ$S5KRsxJNYyEzU)P z)(V$b+SbGf6gLhb7e}^ABq~#q;a4eCG;-qms+1eofZ$ZUBGn{WG=e8pX$$jH9x^(! zG2=LGcUAcg`m4q|UHw!}d);8|&sBQ}8h0=%!#TL+y#@0K-7RipC}>}agdq`^=zzZ$ zJnn^)gff%(EP_U2!4&(;US9Db+a*nRix9X1veD$)7Bt$W*cX`4)=7H-K~ZO{$=fGl z4*%1<){%=9PmE;sxCGnFXDJ`=^|x;=D!rgih@pDdN(nVmQ&xq%S#;Q-{zzSHw;g=s zB^kw&a?9vVVt#(>FDHM+Y&SgRO66aKkqL_mL@z5+Ab_at)tw9(VPF>>cE{Qdvp|z^Qxk?JvJ7>e<#>$;Cfv)?xkT z%%=ri zkrjyfT7kEsq`gk{e;bJaKd0Z6PzT6lyXjJHwGp$*6GAr*bAe9XcF)RRqL$?Uw;5FV zXDXEf45lc}F#jedhAPKRE>lnbYX!S@>D6a_-cX{0cZMq6r%Yhhq-|?LmgKraf|4G| zlXayF!vx$~X_qmb{7rM{@dRB&|;wvIft`R)@@m#{C(ZmU#0 zvqPfSde3w&1RCL642yhHWLpARMU5F;+}ta=SXGpV z4F7Bj3FnFRf6c}q5&g~nTzoK(SD~&NYEyK_+>4pn4|!o4+sMucN40&xG3oI2Ul3hB z_?X1*`Gci#mpvI9s=2GgR2zICe=Gku=?4$-bOyP<74igr;AJmHVs-NZ79-E5bgvz8 zX<_oqDT%ZvC_((WEyV$w8Tk8tP7t=OLVQ0cDbzd$Z8PbI*o7~&-D4{3=4GvHxQWf1Z11)qE>*#&zm8;`F}v;j^7gB}6TxIg z-ZVf7JNI5HHrUUmN=lxkhrG{uo`XzrZj9e>-)-BqPH`YgQ`z z@#JHJKQ6+kccq=A&?Ke*2ZLB2{MBPH6%(VyGn1Mz@2yqh#t-MV|F?!06ONmO^+nZ#7{-X-E3l!G@W zwalL}yYPJY(&LsJ3)qsZzr1aAs^i6qQh>J%iuqM=Ok$aD{ul;!^W#g17BT>eQ~FQA z-Vh%X<@khbpqI?b!lLC@#XJUU{AEMr z(X(x{$6$E^vPFyUG^$hEu}&8Y{_f=)O>_g8S&n#9lB>uWU$SZZpn)1(*vtY+zHji z1al|btB%=KdeMkDhu~;x;W1+M6}ErxlrK}*q3CKH?qGRgA+rZ0IItYHj-Q-2PhE+r z?#U_WO@^hl7ss~zCz*SVl}tCj6Tt@P>JX-akyICJv29C^?mVSw9CCXrKdMYRT%Jk3 zF&Dk2oAPT%%_A;ng?HXy@jJ;7`ytvm-CBMu_JiO9MJVC;~*FjDOCLDOC5Q#bo=^pm9%fQ@FYQwD?-lU_*R@U6L?L7;>j*Vz{Qm%nt@^ z9DLfzCi+g^V;U(;VdN!8lcGeT4(Xo2$=P)J2bqvrnUS(*cOp8fKZS{8pq-=+aVs2K z9N3IJ^$p9iPL#=h?M()?9zBsoGz)mAl(kNbeoD`|Mt@564J{>mT6Snhjf3)`boa!R_2_o@ z$cUv%u6TOM38w{C-j-cPJr)Y7Tj>hBAb6_iPpY~II;o&`>P?s5$eR*bRswfO&z0DjQgRm zT#Rm{EMF8tjFOEH>ljKI@{HP6Nvx8_@is0d8#Ga71;0_i^cJSXHXv@*W#L(Qy<}uo zb{ot_w^md8i0$4f1?}$|e1insKGA93t{fTdOw=hng@<_$fn(yD+^VQRSY~=P`K>Fi zM_jPy2Una>PO8K`rZ{(<<795!ClR~OxUZFd&D2e@vCwj)=E>U`plPfQ*bTcxb0BeC zH}{w2*YtwjR!oy@?eRF7x|A&IkoO@8f6)&5HF_CJWj@>B=&_*Cn@3z?pcp=`}6KRE_5qGDsGLn4H zvN=7pt{_isqb0UAhzkpy+WS6niHnZY%!(+LNn?yw=@r|0&H4ht9mS_NHz?=?n+qk} z-jiRYu{+2yWuv713KGc^M>dl5pVlkYzF8abpt2dsVmD{Df1esM4`XYw@1EJ%{UDxR zFvA)t!cPPxy481}EiBjH78RgpAPhEt+so_#xlC0(UK>WCiP}l^GGD3)tNMm4WWLNX z%d(VJVzLWm*>ld8hb;C^>bMs1lWaznLQPtA2UWsV%yFDc52^idXP?f`8wWY^u34wV z6X4rXbIytI3d{9#D+lP+=WMzEkq&+AzyrOJ7Z3YR%yd4{CWzR2B!ai0L~zNRW~|V| zaEXbHU8O%|d$on90;YG{$mJQKxO_e|@u@If(*ybd{@s%-@cPW44ATjn`i!4Misf#| zGn%2(8Kv5?mGB9|GFvrUS?$D_snYwwmVG@lp7l+G1itZJP*9ilgEKF*dThbf(MOs= z`~%vua{X0KKtY)~?nc`Lo5F2Y>Il{xT%#a9C$`%>38mZ%rj9i&oVXn0NZ=HTTr@ev zm50GE9D9!2uo5OrhEU;&%fz23%37(RBF)I{&FYbxdDvXYm1*pz#RnJnFO&k!mVcZI z%oc{72Hu?2q~cYd@ui2bqa?SPGwJ$O{?`B zP6-J33?y`ou#|5}yR!L*9H}Yd$R2Od7KXcEQIn-brq>FU6Z?Mhkk(V33JtUL>^L0WYDJ|I^V)cq?77o}M`;z({Y# zHz5lQ&m!xS1m-eVD~|b~!0@!XBfDp0kDVfr-MhX4a8oli=}`Wk!T*`AD{eD}+_9O` zPs$6xdtfq?H&&7(lg3MwV8<+Q6|ryTK%}Q;_GbdOW@2{t6f6JsI@c!c!y>W{jV8=3 z#W)U`iSu_fcE^(i6(xF{nKDO&WL9J~rMrZW2!uAEI!upVY(7ZP@&veWXBv2FC`bqi zK`r;V`fGx}1oO-p3%&?lP)T3AH{o1(OEh(!*J7)57W;)Xh8bu}9z&&tDv~Tx$>p;J zMyA0UPMbl8+%I9A!tBz(2+SBEVFQeOBZ04)h~X1vim+V5#x~$Jd4lE(KExgU1~yh5 zZBzKOeZ2{w>~BB)is9|Nfi&WY(?=lsi-zJr&iss$Nl_9Ms8k&sU}_V1)F_Cw=d08_WSajor-Udo3=2>wPw;sqR+}49ymJMNH z-C=>+LfIku40x#UOw}wa*#moT94QEy&Lks{DmZB{R=-%lMWtA8ueF@BVfM9t(l^mF z5&4vlum#*wEIUZi8F^cdP}fb!ips*b5+YWk=zjuC>2q;u6`xPGss6y%A0(Z=O`*3Q zUDqquR2TxX%ZY9@c^42aKr$}-K+a#D4QDN}>@*%52f{k66*+Az_>VtTfRdj|*Y3S^ z6F-hn$*e7sa#nmx=k_zqYQ{sDIR*S#%klFLcMoy#Usg{dVsIL?!U6<5s zO*p;U*llUQ@5%_Aa3!XD-fvsM+O65gmp-*K)F|^6f7qWM=!Kn3U+6Y+E)WGQqKUJ8 z*xz?sx^yqf+rMYWU1d!^pmfdzj^c(-$;`ck=_O?Tl7JSe8E>)H^k_+zNlxKB7C=~m z+w2_8;|2+t$nm9mhRv^8XG88m*{NecUn)D7`qq;}^oe!UAC!u3r+R9qg)h@*orS}n z?jjcE=n!^eJP`u|Mg7V-anUQ6d|EF|G6vGxv|;MYXGxNmWA?4{{<6VWJ$i}SF7ak=D){ZO9>!!ss;?~wM{>(t>7)koN4v%d`6)g)( z(Y<~*@xO!E*l&*@-2+d>#>3k9^s-PCd{Z45%AkR?JU(=Np<`(od0)yKQ_-kH0c26v z^FYq$MWd5ELMg>dltw+Z3L4rc>AkSB-oo9M{Egm;KQmIjPraU9FrXr#q$)i-Kn<2= z-^E&_DirM3-{@9LW?+h7t$FnAkrBLUBgLK8x!I&hiz@H=iKKC&QQV!lpDc1?V(d{< z@aK;o)a3Pt4iv>Z)kGbMy~H(Tc!7@G)54hE>>le(81Sa-NfG{15F_7-Y7e>EZ>6?= zhA}2Nr+uG%t_b!wA%}vDV)z>oDO+{d>Qo}QtdYWrJ--v&89#Q#FIjxas#O$vQaR`& zMea?JDTuK7Ox~E4=%T9*BYpYMK zOpq&D=;~H*dh5e(uvaXN`OJBowr(3zr=ahX=wfvIj;+yAVu1b8_6*XaF|sg;ua9_x z!Xz;UBN8Cgubd|FSq}qTmJ3&--R@a0bsn=^mT_uoNJQv=eR9he)2JS+o-K#C8pB;~ zm@5B>KzU7gZnb6`ljLx(nQaEavLCZOkQvG}L9pz`%?PG&m)KBN*aRe?k{O?iCzo!S z3=rcbiF2Z$5=lZHC^`Mt*)S9_Ya~Npw7OfZV}nsHM^BF5iaa|tT6eXbNtcuq3CeI) zSo6e0F%Wxb@yIL^AU}7DavH~x_T@6yFeex5f4;UT6#@4gF#9ASXRvj}HVCcLfuX`m-=j+-?d~yhtu(f*3 z`(?Cg5ZcGLjL&<2idPG)%C}qQ%Bto~%K2{6I&*-#xlN3A=#Kb2zqN2*DWj}+{*;9w zsitrAXR74RZ8oJ*uFqo%Z#ldP5A=ItHn)g$B} zE^ii>l_Xj$B=j$>}HxLU=n;KISLlQdEtV5#f6b zK}33ua?aT~%47+NG-a#=-D=_#M=3xKap?uAk@a5rHuklk*_f6gjy2I3FV%z5Yes*f z%ucp=_(=2VE3`{avv|gdsFE~+6ZaI6~EfGMJ*b*ZXm0Ci^g?o7K!zMME zZF*DEyg}aBn)#cIKa6a*z#TAS)u%U=dSeT(O7op6r(NMtUof{q9q0vRm1mTLut*O} z+6L+$fcJxQ8!?&4#xfYOA{V)V%Od8*xy6CtCv618cWJMRq+d_$cm zzuffZtX~Vt>-WTDBjaMex(Ys}Jl`zLf3mP+CGA5WS+J&A3gN#b0zDTIfKU1}bbZ4c zRmHbm<2$>ynQ%r^9G`^ zj4z0DJ06qUE_%%yCq=_G@RIl-uxH^Q_qAhgJEnc8xSov{v5Zei+82e`JtH&w_7!fc zYtqT>C%;mc0fl_}&jYB?^a(`0Ewkh$bvhn1V&4o5XDwCRr>aBR@)_y?(=Wo41NN9o#q zf*qVyg@0NtgMAh86*7bKWT@Urs&}(;lxG?JIRwv zVs;(|X z>!r}Hpj_5##SfWIO$+yA5xdBZ!~Fle405)Nd~Z{vz*mz^Y&Le8f{C|@wr_wEE<}ir zd0G%NyooJKBzyM8C!RG_egj*WVxLiVu%hGyda*20fUtIGDhBtX6P5#aejVo&ng5tx|N z?CbRa?x%G%blCgsKmF-rRhB7xk-_G1$np{g5e zf+lg``vD3KL!YqM*lamJTvv$g9#@5Xrc+$(LVT3ftmvn(Hd&`GWDBu4&KfpJIMpON zQ+O=d!%fb^{y*|Q9Dg4H-hY4pk@s-{Isb>e4{n4b?~6z~DeRuCIo&Ae`XBN>+-Luh z_ubMoo*Q*Mxg9^m(aePv;h5|)e^)JTERB4x8kmm^GyMDG{2ySAY@mNJ=5PDQb7jM+ zw_z`STHCzeYEy9Y4})IMr`A6#{1uux{ioA9^AhuCENuTapWS_A_vStEnDWHeo_^>{ zJ*`A9q`VkjHena&zl1%%U)sr zi@JY+$_RGSTb6LXHo^B@D7O4rT#OJuFf3BV{Y(p3br5(5&VqDa1^NA*Pc!EcG+5PCNU*0ssIb7GDO0Vre{07cm71R*i&^=_%%2KaIQHbg z>|))Dcv~w-Dop+RqT%Voo65wSzuY*XeY^-g?^zT3yVH!bVt=0*o>==FN|l8?ba@&t zAq9Hp3BS_x_^u*UPa+JW*V9cQse9O+_iV@T{3}<%7iS|0OOg%W-yHlS;E$Yl3;u;~ z=5BwvpFOR1KM%vf7-n&7ATTYA=Qyp=Y^{*yAL{=PK=ct3QT3y0=%)sOUvJ?4ihA<8TAYT6g@8 zrVk{tAG1$>6{@QmV`RNv}V>vt?;5A;}{|888s5Ld;WJ5^v=#mq^;xdNs zbM^{O3PFo>!&nX8+uWg1h;RQ&Q0r z#xP#m@s)dbU59^h<=EqB!yJ8?wxANYss`~2Pf@vLw|+B5TOUc&-v6c4QW^mqE9=*m zgbPtx7vTL?xO4dP^Dad1C|Fqeq3@%MC$e_X<`s_l>#K=7)bja@+s>=`+bbOwl8ZZ~ z^M?hOf7t~(gv&<_@3%Y5(|`iS{%52iQrg%uJ9}at?nV ziHx|B=Xv723jDy4-f?k{+w8ZpzS86ICjuQhQpr>@Zyq(vE8hJB&`Wei08W&BfBWb@+F@|UFZrPJS`CKM zxsFg9vbpo<{I1isF+qljm#+yw13-p6vyp6J0#u=-D&j;=p+E*r%SJ7d=S~PU+Acip z(ZD2EPTY&${X|3S2qOwh5jQ$4hFhcd<%zaC0Xjm6LnYebu)t4MkSp2zWcgNQ;{C&e zg|xtKJY_Cd^`9q>6Z8aAwp_2MX-@mPvSnq%w#dj>Ixx*rO*6G=#~HSU0}oT>ujqCd zf`UT#+g@;-`Ss0JUQ4{`l8OUGSer<3XCP%HsAAn` zv3X5N_doJFzx#&uz6zDbXVUfV63@U7U#E(FP!@Xj`lCagP0vk%uKGvSFKPhKKQ|hh zv}qsSFNDnKE&y&S_WuEJ3tANZm!u(6^dF!L>2}yiGtS?5>K^uMQl&u#6?_Zk+rg1Z zy4`Y>JL#MLeyQeF^&0t{(?1x(l%aZ|L-Hjmn&Rx1gEdLE44jDmuKkxk_}i^kW>DCW zOJ$og#}6vGMEA>t%Cb<0`cUGRl-JCLpBIU??hS230;Ks;GAChwjIMG_f5E5!;<nbH77r&_om-`ifNDy|~k$ zzn3+#`(f7Uk*0z7aIE?-z(2r)0;al~V`zilJ@mb)yp3N<9YPI~Wp}#0pJG( z${Pxge}I^ZyTTh}T^21z8_}-%i5pBN@kVa?_qTUp<9-o^#{Es5!WwPjUtWGrA&7kV zXPT6c4vv~dR}C5e3VLvF6h~qfwjTyRwm^5ul{;Ux|EJfQ(or1gKDhs;1@dv^zEW-E z4r?wL#?51_iV&VV73CW3KR^`Cn=`}P_X>Yr_AYx5@$h<&lpnTB-c}MUxBgvKN*cb2 z*ZAV;Alw>P|Ilr~8dt^^bo>vX#!2)^m2iyY+AXo^VKes9C=Yn$NKI2de4Gu;Y50!(CnleLr`LGesY$D1PInKg1bHDvTsB#4We) z;|EGV|9)%O#mS~};0MVsH$CRRSiw{g)>~#jVlyI#NEspAb2?QljFM5*#b0AEA8~Gw*LW^4bOr}GXK_$MS7Nnf<0Z}mx2D2O3?lBwT5ShVO{Rgf*eDW zj%+l`*h_ISLL5H}hgRi5m`E>%(Wov~&%fJYT%;ineaof+qV4;rE&cj^q;K2!)@0R1 zr~E%a{vsiWc7V&;8)aPCP27luhlOp;lXAYbQk>c-P3^t^r<$zywqsQn=Sx8h{~y|= zrBpo5l;2oxmHj}10qup>##IEjDN*C}9N$<|L^o48HoNVu8{~2EIupI1HF-emwu0V)Xqjd*!81GAATYbo}fuv#BUA#hyc!MHs`L&qnx*b<63x z{MmohWqegHOf=myjaPNKwj`M?z81h ze45>35`;U3zrPLpjk5I`vP8iB%w5%gsihy^hRxj7+^v^!5e!K)E*Tqd{{3+G=I+P& z_!UFpww;)5g^rxE$`>M$e*i&Q-oN+$l(y0gH(vAo$)Ec_M7?)hlS{BaOz(t*-U5V9 z=p90l4$^xENgxPFZ&H;|6M8R#NDZMWUAmNjN;6aeQ7KZzf`B3(y}x+Qz3=*Z#wJ|E+H2Z6acJp?yb;U+I5Yed7OopLpLm;ynl#6K3`ietR;I z_R-AWuYol25{Qvgg;{$`n@5{Bwuo=ro6onUjM$0RxC<8`wEkI>(_2fpaRQO%*lzln zD){J;*gpE90A@$fBu~g2By}G(-arKneO$xF@#znzXT+8M=8dN~*(;Xc0w#Gqe4XkJ zLx7L|R=%|D?jM-;%1%KyfMQ29x@2$Z4Ufd;sNc0vPT%@)eN`PsI@x(8a(H+{{palL zOk(01(EV%h;wLEzpc+|Q<@JAA?>i3$&v_t;PKhLg&May3O4~bc+Wx{ZxpA`jlb$>a_#fl{jdDZ3g z7(cdN=aE*}F!7--7Ek{;ehhHK!r&YYpmyd!|NDV7CZmUB`y<&`{r49u9LanH^4yMc z{u|i37y->o{Mf^8JAA*8IQ&{kh2&5xtb;muLbQ`|Kb@=aUUm*jJ8tisl>i+LVfZdBw1W%sztQt~`X%V8i`}Z|xOtB7crKWsZBXx75;m9` zG*0ntj7B$n2~Y*Mq{&kEjlm1w(4QXTJ$$S}@@Yk+TBI5o#9g>eYgcxZM3AlS|7Q%` zeymg4l{rUBVX<)Ekc`NZ_BpL>YVgEWF$g($12nygi@k3^WDt2u8$DiY-zn-Kd|mu> zpp5y|joR&D{Y{e4D3n*i4`NPpWvEEuE1;De*^SzoVc_>9USgT2svpNbS_Vz#`=rt_ zkJN9~PHgKCi4mpvydseM0lQ~|k`7}EA)IwktAET%(pb()vAou~myvTu_VZ)j9Lblq zMTtDNYjLqEoreMaMCoYw@Tk2zEI-Ydv;TvkUl90D`XwG+D|QqgA33viIc6nTkAVkq@p3iowudzA3m`{VXK%J^A@2W`|90uthqN;ZMds5w z4!wHUuGR~+5HyGTJum9OURs`#P=wkdi0ZYm2FjQ?To_qY%gz@Pwa@A^(9n+sXj(m& zYpASR5>ej6`ovqO#;KX}V!)vi_PVFGc+hVU&WZ8H(IX#_ua&C|#~`9w|Gq$JAvp(kLbW{1xUvR`$tfF4B>K~y~QCsW5&{Po^m zfQZ*TB+;7uOS;d1Vt;w#Cvj96a^YpAmh_SAFGD`1JtXO8y_dN<{{OxBu8Ki>&9LbW zL(&+?>Da%r){bA{e8l|i&-VSlUlP^iBQ5HNh2dC|`YZQ9((3>v((~6m3j$2bHqU@E zM0o$Na(6xE`%0d-Y}zj?_b(>m7j9Vm-=I=VkCtsTfqY)nV7Q#vF|Hr@FBorF7@-m_ zr-{J7SP3B-iW_pohriTHz=_oUR%%T?{|o&X$d~H?B9MHEA5zDD{#GWksfT2*O_YN8 z%rZ{$ciQ(JB!AWU3-B8j!})A$MD;rE8dHl3_&e?2D)i&xv#Ufg{~kH%wwAw!{2gM z$+@;LlcYL%YJWJeeq1sTU%FN!fP2HcQ(s|1%8#{c0XR$iL_1hl*Q~wjx9spVA)!0e zoVus%Mk@H^^WukiluOe?<`=GI2NCV+I?g&y%cQRKz7~4l2_-?(%#S<;c+LS|fM_Ro zJUlhn%a`x@7Gia&UN*QT-moUmGIWfZ;-^>N20(_C-a%t)! zHoJ-{0w1dtSqri=Z)QsyV`4+L5QL-k9|+AgjBoyvMg?P#BgQZdn(x8c3I|V|&|x-MN^7yADqPq`p0eO8wx- z7alXwF?0!^wo_9}?3NlU?%8!MI)hqImQNi`c3owB$aa|cy}(MX^Ya(iucsoH|qgRkl+ znqGw+DAPH8PA4x&x6h%P_BmDVy%Wt5(pjeF(G9dd0xm$t3=6Wl%ZMxeTMBf&5<93>kt|%C34hjvkJB!KVK4d zYA;4f(tSzPGz5VXe2q6^?-vl%w99sy)JfD%gmNJ*)ARobec3Cj3G2LW)4ihM&qgQ5 zzLN$?SP|^(^p6CFEhE60wmlrcEb{OfK9QGlU(5s=e|*<(NTlFMR^%n!;FC~OZy*VQ z_&9<0zU;m`yMTm>Jy6r{FiPtPJ3bM|xuw^V$&DC^O;YeNyOIO$w*I>MR=J=8{CuSs zTf+;e%e6?M^e*;K&%#5E<@(zO5b=*ilBTxEetjse4|Y5gik=ndz5{+TMBZhuS_$}= z1gS^@0LmqGfv#wEGJ7SmJ#>yFt)n(%MOZK3$sBy!cqtPtZ>BhpqU202G^N_KouqEf z3L5ed@B6)MEfSqMzIC^nJ0hPvw^h9Vu^#fwi1emaX$1Bh(8FlxxQgd{1>Gi#B%fb{b|%H4>fiD;5`$__J5qh?I{DagM4yCPeEcq`5$HYHv<)=lgVin<=M< zj_TMl^`@}low-r-uVwhTPI<3{=k|A5UfI~-T3>BNNjB)Tf6{QbZ zqH~$LfvM)~e@T}{ymIACrL*geZx7rddnRlcGkAN(McVC^D_so2;WwUnQ29i~;}mJ6 z!cC(I&Iy3``)mb62s^X~Nue4fj_Ap(ZFHAnIHkQMXUWbjdA8yNvmqoNx9y?uem$v9 zbL!-um8+D`f|7ND$U3dDNN1isTL)r_8a35*m#K<~|nENL;FaWKG$SbyqMXVTAs3BN^o<;)23R&N$fLshDog1xt-iI8bM_x zbI-PgvL^6gcUWyRmj0usGI_=B-HIwwn(B4y3+wmfcq$YI|M0-Z#>WwDXDPG*cq%v@ zmg=zoE4{))(2#n@^c6~kx^@=^gLNO8G4H%-iKBWZw_eV+959g<8V7N5H2Au13>`tp zKjwq%FUg%2t>@FLrDi~H26Na|!$$8NawS90e=zDCCHJsTPyIf&ks=-67CAcV%zesR z_>a3=#^{H@Ex&+>ehyL=dD~t2{I33N{aMD2!2#V=32#jw>9MYt>Gq|3@VN%&kEIlz z9Qp5_yB%7JwXHnqv|M0zz8~|7;A5wjTyq(r;(I%f z%iokD=H~_Ei{^b{XS_!pi;HEl3j5C;7-}leS2^B;iv6qazAJZysgpD#f){iJw;n$c zf5j?U^n+2q0Z{U@xmxwX1hFV}`LPYbt)!{yINlm3;x~{J*oN0=>?e4qDnaTqzQ~z< zC=G$X=&XXX#opVYiA+Vw3dSpV#lEhyNBYXY)YLahn5dUDhRFv+)XRuYOaI`ItkHv_ z-hZic{jqj_&gqpo{CmZb^zXOpz2ghXbnq%!QOUH05Lao5*!J;1goez`67?Zbf*%|sHZ(Xf10J(%;@CO0?R zqPKaR))f@oH26UXhq~Eusvp}=DDGv;PyLW#s9(y$WvP3O@vy0O;f>4^h+$My% z7D}_9=BuAOR>q*c8Y~ULN=lwAXfMqs(TYVq&GeW?_eVI;2K6HxA3?ItbMXM{z;}n{ zk!Cs)w2FM5!(jyL+5@{y*3IT7%(TnR(6BxJ2L!E)iJa2I`6HNbS83YsO7>9ouDYW- zXU^%l825T$x2@!ifxz&0b^jO&KDlATLo2>zCTMrd5(b$hN;>)&4-`4d%>w6^x8!Dz zt7Ba4LCfejj8!wuo;?)#AKZ;$bL{n4(pP8>lpp8vuDXr;h+Mm2x&!G&N}ea(IjPJo zEulJN9C038_Z?NHa5Gzux`)T(Zp33*Vi#Bjhi#~a2fKz0g*UIULXUTo=*$Q>rkZ{7 zMT;b~tW6Wh@TkCr8l(pK!>jNNVmrDuBnW`k&wsLF$y$%5wm#x_Y|^a~B6wqoh0rCB zQ6u}%tFjcsEgO4l9y_Bj7VpNVTrayG3ou{wpOc-{sv|gU!+JI=Dn{_Qn?-ULGY>e` zZd4Q$(}h^v8m7M?$UVSiYK<_>mJV28)?yh|c@X+r-4jWxuLaZ8?R3_yy!Nu`MWbdV zGhf0P%*%Z_PfB_h7b9`->^(M1A@{Yj2( zI{tE(o8Di$X}ptRcKGyJy=fV$WyX3zP#LB5apjB41=J;7rA>w zc9BH7jUN;|0HOte?x?zn<`T2`jtWb&NJ_57$~`65p{J9KKcgn)slLA zajEeU`p0QHK-@%k6W1LmLko;G>83$P=?!!Bt=(_aF7(3wY5=b+fp4WJp6Hr#wUi{s zn9O&r+=GLP$(sIgj@j+J>Hw}@qnyGFt`0~ju-0^%lA$M%D+nonVkSXG3X|LS9vaL&&3ZJ}`L89oSL^&^t zpGzt9v;|@n-tr`|%|feyGWNJa945SH7N*P=(VM@nUlZ-rPuTq7t$=SYKAPI)QL-zf ziIozMlp0Etdf+25fxA&6J_YE;)Wl=N%x9oVDk8w)k8Q>d{13Sw4~$Y0rvo_?Z} zJ^|i$$uezrmdZ^r=Ye2BR~cq_R@Au!vtj&o@eDFr(!Z&kVO|EmTqAGN=uKV6BWvWC zi^)oLXK15lIp4K*9pxjFcKv52tNt=#X$#i!=?q2S;F4orfGTBzcoPDzcOAs9px$P3wN_N5>V2O%7*V(t`)~3u~&o_U6-elJI2>F*s4U-zVTTCG4W% zx|W&czF;y=NY{k~Tl|SvRFN?@=+2-i{P8d#yIs=k%^WdRo72AW&VJf5x!HdDS!Uvv zyGy{G3U(M&f~8h{)UTqrgM2^h&iYRUD9 zr-Vidb)Hx|D3b0d@CPBDI_xjyx8-bj1x(W)vKb4TMslZ$YEulwpOkEj*ad+7o0Gg_ z%Gy79c$n4mmH!@c$rW_WCJ~3i)J6Pe`nes6HAZmUjWZ>ZrUV8h+))(Y_*Nw2bDd<` zj_^+T2BrP#^>H87)~I+86Z@}uBtKbA^}6gTYix(%zF7xSjs;tW#^ZHz|f`sCSlf7%Hg{o zoGd`n?H!(x23v9Uz2c%Nq^s)_Fk8POOq__n^HQCWxM@E##--UB)oUkyGiGp5Tde=% z!%hOspn06#a|~q=eWb!E0tY}s%zE05k4Po!qbT+o=qdYQSiEgX*uuH}bMvmgN@n}$ zRHU~lii|TIRZFg30+rL%+-bavzl*<(Pe71Fp`$E3n`@f~?9`LXkb0ka4cjP$v)`51 zadHw2gWcX!(6B8M`y1n9^YogS8}*s@0$f(X?PplZL(vot9!awjPQSk%$>?ncMuvQ) zc2a~+aw;$fp|~sLoOi+^wNq0MVHh1h;T~E*Eedtf%j(MIJLZ7`pdQCQ%F+T&oK2&Q zY>T?HHBMs4J8l+6_1%6amGS~_8G~>mAwi~ZZOhMYz*d@FN*Z-NA>p-Tz&i#Bh6#q& zC^-SSul55`BsB|h@hokZIoRrG7{Xl)m+NZLYzi$k!bbXShpFCgupm9 zl_uhaI+hFdi20c6+skFZHLB6mSVG=(5(xP6uvu_G)9)B^h%sm0a(ja@1?a?gfiDsu!u3gXW2%#Kp4)Y{E`e2_R-4Tl0bx*yu;l2Yo)mKC(fI} zVN$r_wU5Y=-=@dOTai%kefGswH$V4!c`vbtn)Wh%SGkWHkhhKOm72}SQw)mA@CgP} z`D7riz`?ZJ^i;EQ0{A|}7NwVEybvxjXF9{ci|At2fjl4qRGTI3m(!J*xEfvdM%4L zbPr>L$K|8Q667mx<&bB}={-&;2vJBYks)cVuz#K~yB+5XGdvD6aGH9a+CKH<9I%I* zY`b8bPJdN?DmU$UU1NHLiy+wz&)rn&d~5%S54R*2IP%1LQz8_V9knisof)tef;!?% zmm2jJMFz=i$NlMNs3K%`azB_#$eIJcib;?!qllg7x+$v}_>Ed#FX*m!r%NpH+CCboIB_xAd*X;58l7Ye!TZ;l;{fyfZ*yS&74ebAw2EjoUod>j4qV zVD|~zELMf{MK}H-FxZ()L*cRGweJKE`tlu$Xp26@x0EbdiHf0HXIB_KRsmfNceV6` zJ~v>Q&8Eww(zA(sf<5^;fx_jTM-*fTZc`(h*hf-P2hiMEQP54FQfIkll6YqY(E~EC zZ^0_f+K$)8@Htq93DI)iMFEOt42Z=5E}>3Fy|CA8JyDM129_q;+#dDKWNy}}Zso#s z#X>cx1JHrwfjY*|QCS>YGrfT&E#5Z*^Gfh4JQ>Ar28%ISuO1N>vL{itgIVs1S{1+p--)20n3yu##R9noP>zZ(=1?UrHKV>ghc^9&3c4%%|&upz@Ix-f3BI zCg^2O`g@J_=;u0H(K}>=dY^T&VXQOAMlE`}%bwViT>OE)4^V~F&(F_PM2}7nR8Xw1 zIXQ}WV;*_nVd(q>JtaKd#US*Lkl-Tc<3-AwT`(G(iX{+h3};xbH9N@E~a zvH||KwNIE|#v6`-jTR+cRSK``d&7c>yS0T@+dtknKfw>>m=r&hdLky|M+JMsYwxR4!`)H`otOQ@Ivnej7Md5`fC{!< z==obf+h|6`kqkri_Qd%~7%RKMd?ek2&C)2xIV1Pma`5fh91BHAd{yx=*ItTw6gxmh z6M#u(_!cT^Ybr#a8-5@dYXZVJZ-fy{n*8rtU*<;OS$?y}d3^;v>iD8?BxS0u%GS5b zZE`l05w@g}QWJ&o5O4xt&K{+W)rV7#e@bD`$iS2toUt9h%t@ASrJHYyUA2yG)Urc( znCRTO>#s{O|EiEZ0&G1MbG71ID(*e4pQ$_G1s$80|IEBh=ia>;K2%cr!kDs1-o;E4 zNY9o^6LCPV2XG|GI7KL*rS8l!*YFlTmpqri8hn#b{90!*w1EF&(Ey{MJEA}KQtXjt z3gncGC}$>#5g1|_SDeZEHo{&xQ!cE@(Qnso5yW}Y-sZ{f74SKngRM)7a+<1q#kn?< z8=`|!C?g3pJ&RV!{=hp_8tdpvdrqJEv*J*G+l8%pkSS{)p%aHmy+1A+WRNfmuWS*$ z=|s zF~(V4x8W=P{cYf8E9>-Wy5_ka4j(&vyzgVbx-PUyt-@1w@e;H8$nZ;(CWUn!Jyx?A z+*XUgu#Z{cFSAH@sV|AZ6Ub+ej^{H%t1V^QJ)S}p@p~hXj`XkZceE=fz1*yq4#(M4 z85L)`6&fB@ZkHsDikH%muekViwRh|!qxOvLa;l|?=Jousy;X%oFzqf#oO#t5+E9X4 zqI$mS7@?lgPD8~PQ1eZp0a!;God7g<)FlbJ1TgYPNar2Ix}_`6?RS(8e{Uk>{9PD6 z?T;qAH{Y_#_GJk|TT<+jeYUaITsH+_|!rtOlaPz7{M?yN&2lK6$} zP)!Ow9Q?I>z-JT=`iVL&!4(~!Ju$=ue3BL7KP7DvlLG$s#w%pamuZ0h;>JZs-wH9|7om#fWp=7 z3X5kk8*9hS`qr0B)K&+AA3=d`=6Bs47Y|$dm`^mP`GlacrJR^u9~&Ol1XH8^u~#~= z<;lG>`(9)3hy88W-~NH0Bv0CzOig0%J~7zxi>gvO^j;EN;=R5A*(XWrgU{G6UQ*4B zg__e^hzyIQn9s(2s6yH3-ekDv?wH|ajr-MA6%|qhT zsga}|@~7FgmYxvApE4=!T^k}=O znxF%o=@-S9vKxUtq!SPNx@YP2ysbOJb(LfuYopz%T-jxd0|sa5V`pg?_vVyFU-=_0 zk1B?#YAZ8lggU=XbT-0mLobW;81=)A;l09deO}=#1FQ%U4zL7E-463Mz1Z)Rzh?kc&&X#_*ki;5+ zu6Y(=)Ve_($4}?YI7jW$%R?s}S0 zTy9<^uTaT9A1A50J_Y(+&&3_QK{hrvj;ID1Emk*oI2YuXqUv&>jvvc9N@$c{s(pOe z%=dLO5Ym@hIQz~e)-2GyLnlqyMope?JS;oYy8O*cPN{?2GkWTyzHtLKkGLUe)T9S7 z@H9bd?F6CRUt>Zdmj$$zQ-FHHvJ68!hHnh(ZeRx_)hz@SfN)t6bP0=*`O29QrN5(ZtbZhik6Cc5v{~ejS-FD}g7CZ4lci62a!2z79{-2`c4T zmwR_^TEb;JwAg^?AUXkhJdDhA9u`oC3GotY@R`(^1cuqao<6S8l*)fNO!bu1}vpM+S!`_A7?sUphBSJoq z1Dz_}x%xE<)9PKU?pL95t)PZpCiY-s*~ zkHu+$u5u*Jimy+EElcS(VXeUlmU2~#(5ebUI9i!3ZnNrVX>86u>jl-TgdL_jo`n@+ zcr%Kx#O_RnSFSoQhW%cJc@2d#%9KyYwX2O`Pe>FzkE?Qx;Ilx&JvL+iVmSTl%_HiAf zY6oNi)ld;1DFvTV% z2G&~JZ>%gR@u^b&`$8lEIde8&mjvIQHkWkhlFUldU+=lokK0V?rY`va_=}_q|Cw>h zJE%da+8&CSld(-xu%O-N+VNSaPE(##wE(JtA^bDzb4)ya`4eCf31*42zh`B57TGPe z0BAaF>2yfh9CwBCcBlq2@ADkx2$c*@^yO*UY*q#%3irz7A1S<7cwN(Ro0w@h6lXLU z0U$Cw5>WCnp2~sCT6jE_N)M99jHa5Kdn&*5q8%!Bpx+o==Pjih^36z@lF!}=6l*9G zvpN#D!d6ppf5uDj(F%@!)yDRjIV2ZBY?MGW&L&}f+T(R3fZzn=ko2j)i_Yug_R(btG|zul9&)^*P+Q3rA5T+Hp)v*qs$NO6Pkc<1$jN#oQVwh*@VP5 zjC(585tb?QV4b*>h{ooqvcY0CfN?&jNCm zT|L*a3gq${o_e#sbMxfDO~gL5NnWQzM-U*!J8_z6D#yaRrB7cj!kM#(mXo`LGA*!J z2~)brf3XsFu&dK=P~s7xTr-Hl#B9+f+8o=6U%nZ+1>6jTB}6yrym`sUzU*TfEmX{2 zzTJm=eNV7UtP~icIVEcj|T_*)!Qtr_xGBr_z8#kCt zpUE%XMzi2NE0~X>B>Qrc1m$2FDu;M^Cm_gNKy!Y~d`#OrTS?S>ns{|{#UGay{@8o* zhXMj7RH4Hnl$b>OvU~^2P)7J<2CKgiYu)9s$T0t8iRxbrQZZ=q-iRr$sjkq4OY0-a4ld7H$pmp?(5WXEL5iA4GA1XO7h@aUl&rBl}odf*S(T$q*);E z!2V-;KW%xAxFNi>s@3o~G(RfHhz?Q#zR~ZnVJY;cl~d&X+{hY!qSm<$q!#XwCy6QV zU}#U#@{y9HPK6C3pwm2|%cdm*HwEQ-t&;|Ov%~fKMGR@MQd@}~NsSpC)Kg>h=wZBv z-f=@XVSZwQHp#t@<|cgvujwP3o>;6B&)4soyEmpktd%}&&TY8{tw$#4`x8_jfGhe5 zN78+u^=(^K&Rz^V6*GxQhqg#69@om^N*<(#Jybsgz7vU*bK%bvl0a%F_iEbX1+Z=p zazm4C*lVT)yvgNt15MeoV>6xPKR6d4iu*US^;X8mWg_m9jqCB+I^NEH%B%5EZ@;0Z zXIRzrtO(;8p5nXG)N+?JC&MCr4;yJRIYiuYV%MxcdCQnAi0Ep}ShFWl=+!^WZW`yo zOJO7@ypS$AwF-h_*w1viLA!!>ban9WZ!#~#j4_2`rhf#;mI!DCE}e1`mzy^6B2&Zi zEqHQ+V8oFKO;P$iznwDnXTq`g{ZxS}mar}uk{M${K^fLhI@`7}&f;iKrLoB#UUlj=(uWhvmP{75`cAC&{Rt!r`Q!Rlj?{|c}_$3*k#TEL4xeN zy=y&1JPaezoFyAFJ=qkL3mwa0ZcDaJCcP6_%MU7^bLAjW6z?xV9Ka6%LP5*q3TWKu z_R{wviOZLy&e=jBSuRz1iOhL+=W(MCNCdGrba2=_LL=BTE>$BSH9YDq5hf3h8L`-v zu9_yKX)0@TIr$^uXh`jW=cuUAJ?a5t`II|I!5wqgU3XsdES$6%CYGMhmn1E`h{2+S zesHem^_e=oF|JtI-cH_$LBcyUM!1Q~oKlDEwDXA&xw;_tQ@JoMzHSK54!fk>G+Al) z!Z}~HP_6e2J@aR9n=(D{Dg73#wa0>{c#0|xn-LhSq@hT3=~kkD4zj+@=Ix~hLW;P& zBMbH3_-ZYH#_9I#Xbx^2P`GZh*FuMcP3#^@XsBOjs?O1?c+$9YaO_f3hreo>#ZkQLA)Es{CV)d<7nuU|UzcgSj54P6*5y zvj?2>>Swc;xQ)A8C@W}tdHEsSM~2?jZWPGlbg}9hdv@bn;d}<%OXuk{y}`$~Gi+iA&{v?~YO<@T_BSz}c#Y>52%5;FAXnH)avkdby$s z%J2mFco_al;ayPa!C~P>(A&;u;X`+DtGq0HJzIudsfJ;so+cdk3N}gIL9wp6 z>I6M3O_ty-!xON;047~TPpyoKNzQA)ocq9*^E9`)`Taw0-AyTk-38V?Zpqk&iPoQ) zATBd4$+6g;ndO?fam!{!GG{r7tK{0`Q%?F*nzOho!h{Ql*71r;M>bJZODcr-%YjU| z2v$j%I%}HtzGSZXj==7ahd8dHw@Wes3s2amM}UMQ(ateJ&AkU#RyFi(X-#z3q&?}&cDU%<|E+Gfmj+7s&`k_fm2t;88E@mN?E)NVW^50P68V!w4i{1W`cnh;1j zNB1ei{uAfjJYVBMmHd_T&4;go5>=aQIH|)+qnwbgFi~0DNr)<##qM8207%}HPoy~g z0`Eayj3YY$XA&n5VJqb&RKk(GVgVuds)8WRy)xYKC7(zUKU3aQUOJiXPlFaCHK^@k z`;Q(}q9?om+YE?>en;HyBe^EFcZLv@T60@zzEi)RpC+m`U3}E3(W^kx@eBD?4>E;4 zkicAYZc})$p#I;#5=%FII*)$kr4sK&yfbAQmqD=V%wepX7J*fUv|ZXHM?gpOH7}iX z@=Q)1Xzld>mG}rzH&wcRwV=n;uf10^6d#`?S`)(p)P7weT3sQOnPVE8#HegMd51RI zh-mxY9>V+5uboz+F`4INUy@z$iK`FkCTZL*#0t4 zGD+o)b2lx^EkAka1SI6G%me2BlQ-Yy98omjvlKa#RQ2-x#kgME>t>?Yf=w~*j>9f@ zX(-kNeMryp@9yCt-Vu4XTuXtqQ%hl{a3#`n#I>56vH||t^LZY-+}k2a5&<20;Dy-J zZ6~cYwxb9oWStf+;4ajvj>Ep>V3%tmo&OgiEB&plHqIF@rI6X}RqHX$RqJ8e?QP(Tox>$_bSJc|1}$Vcvn zWOAqT)9J4rI<5sgQ*rej{w3mK6Yj8({yV$&qb)>{Up&)7b-a1`wlc@6KGn5!!GfVc z#dUBOCbxuzrL>F|8@B~S()^1RE%}vuDAzP%ca7#*wUkgpzLo&pRl?H#Wi{@%&T%DE z{R4iTAwrh((jKvg@~sCQwG8=95kq;_vNget zp1q^e30b5N`!i!06?QU2tCpb34c<04wzN>kkoSPsuXk3JRsMPcZx6kGupUJ)+ewsY z3CVcOYDTj)o_zRR_^O=;fNEZZ|EE#W9~$hu0<_|ynOm*6!Eat|h+>!j!j-$yawM?X zx?04~h;pXcCho(oyG5BiAI=DLGW^@*ghw+_87B3=D0uP`=NK7LgUzyH$OmP!9p6*tJ(>yomOPJp)d44wG$)aamIXvc0+NpyjS&dWxt*5l z??#~I>9W;k*9fRh<9D-D1kb!x4Vb0Q2UE+ZG#r^zQVV|Kp1Y(~_}gT4UdF&tnX#wp(%wooS9WxQs&y^?V+SQ+Ld+2+R~E^-`pk{#&*rnNa; zcPP2kcf#Hk`*%gjyW_A>QcqJxrsY~vTcktCE!)s8{4;o$szDp==NIkJ3WagMcfII0 z0t|mw<+o~oFrql{*Yxv^5nHpb{UXyEOuaV=9`AVfU5segwFDjCl$T>@F^LEbb|=4D zw(q2^n_!mMjmq5o3zG}kHWpC3N7;rz<}ZOy78dD0I{!5R$8x#jWvlI)ro~6bJq8ZH zSWg)bz5Jp@_w?=j7Y}vw9D9W7ah*%QM5XV|#8Df(3kXzsSV2(vrf;|ydMDABxpB6P z?i2sb`~E}ohX(Kbq7Rn+38T#wuApT=$;MmbpJdO!Sja$-Hf)X89KNBtI>8D8oea5Pw(O#~nt@J#gkQzLyjZzan$bW0zpm z#9xCARwE2uBaV#N+;zO_gYL<}+s=GJ~!hVBvba zj;Rq=yCpKWfS^HM6RgC2TJCT?Uy-S|KRoU$D5j8;RDE;yqbM;zTyRPZjbK+_CyG3C zZAS4$veJn}&VnFh8{@oBRxPm0vCV%+AX?V?W!Jnl($EJ6)A=7VKzGO@SOs?nZB>iU z>6lunbxvPQK2=gyrPB#<&AeVUzxx-I4Xa&`w@#8a$G9cS-7I0}KUKgfRqX}#R*QYJb~!9m(> zo=R_Qmx@fc4E;ok)w5ZTRHTsdh`NYNZ|~ZX4Cj^gz`qi?uFpW`Q*;RwqWuG{4Li4B zj&|~L2B4>FZy67*kBIB+Q%FauHnzDc-;aHa%z4&V2jgR!yfM!9)YA4b-O@%#JJ=B% zq3>xDNQ{#ZL*%RA$tLwl-42RG!4cj9YjZk(svGVqFmMF`r561Fj3N;AsM)+7tXN3r z%kUvdNNTH*kTAf*9(JedH$D3OCyIw(XU} zk_((po%)o|d#W@R#*TRM>hdjVZ|X~|*u*&=*9e*H71AN?7%paYj8jg%9|tNyh6(F| zLIC`hU!w2^x6m<%!bRa8=0&y89EudDpCb#}9bkLs=j5 zTea}~gfuf^_iBv513jO|ZE9Pmx5vA7u-^fyUy^segVHBLFD;%26P0=7m6n{drBco- zJbNMqJ3kyf86vC<=^>_V0k$@yFT|c$+JuFVmrIp*=kD8-$tKW~UQS{^!KU}_{3}y) znXpX`8s9=2s+ZL}=@GnAIypR5LKpHnjH_KQznN+G8-a!N?TAt43E;8RE1(_Dos3Hs zIBtu%%c_{V&!$Up#&<8-klMgam(P6fjbJ6>pRw&8q(nMR=P#0vJUOZHLSZ9s-2jL# z@J%Pa)Tb3;jqZ3(w$MXU5$zzUs9l5^6ItA# z!c)B2{~`IIn%~ z>q$ZuHyS|%4(>Y!@!Re&-OVp6gN%H;-xsj;GVV$OTm30MSo-hs|Gi}Td1e*li+E&! zTB!jRhGd|HQqEvq-^ZK77~YkR#nEOK@EE30c`7N5r1rx+%yOBQ=MVP$H+YB+oL^+D zpG5IEXSyp}{_$sG(|b~htdIl-YD0ZV>*-Mt@qPhM+N2D6HkCn)n_Fri6fPqr@>}yA zQ3vcAqN)En>$xuimo8#UC<`QYAF%(JNCE?cNTo`he_hk84Num!WCGPvsOuCiw1bNXXR9-0;G)Y;C{QGT1+>Y zoB~i!AEri|(Qkx?g^UWe);XX^y{fL0A4|;|%6H*SWI9qpqct%1y?89rm&2Mn2%qNv zCXL;em$0gFm~5b%I#c*M($N((iyy}deaPJ`UaN#VThYixNTo7J6CjsGkNtv`l~o48 z#AKhNqHM<>V;#qo*3S&aRJTGeex>+yxaEq`PD+W~q4G7W7#l~p@7JubRq3XR%$5yq z|4!HpRH@7%;y6fY=bA=}+8E7ZRZ~`2Wna%JKlOuTq~246QXy>u%qZA}lw>m0#i=Yn zq{~84b+6acm*KVjD=Yt+-8=7-$0Dtz_1rTiL|Xocw=8w0XRd(Hd5>BhWYd&jnJ=A7 zjmpB>jM3Z@hq{fXzgUkRa98G7UTo@*u#y}lCut+M*ekD6gWqn=ue~@BhO=6-m+M#w z#|Jw|8nsAPL7>CBxkrp_&9*Jv1tt8Cc^HXu2y1R0CaNwm`u@X!B+`L`Z~x~%%i`pV zyazNGs$Qw={^#XzF5*p3_d%z+HC9~_1x`spFPM`+6_NpG+P|n?dPxzJoA(;fS=;OD(oUf0>?n36o+nPiZWJprcrdO+z1b;ahAs9EMG;y5^hQiMZ*>@HPYFvkzU$ zBv5=@u=9Q;^NBYKq*sa=D@s(JGU)XKGpaf-)XlD zEY5YZ>i|KgxANh8cd}zl@6XTl!S+U5|NRv(kaR$g~g#-C2!7}DMYGzwX zxIgEU|F5Mh@k=so*NLX2P=JVr+6XR*qJq2TbW{X)bIlD+Axce4vu4^S(Gb~EV{uP# zFDn-^w9N&u1pVA9wWdiNo%YFWnwpvKob&db^B27D?|Gj4dhYAGuls&XY~hwR+xt9i zPG@~8|5wGiol;BDqy`ePVmanQGlt&$=>%P*oPK{NfzW8K4Jwr6XMlRGD?XdbfzKQV z?p^D+yYptw?B2(#8p%~)ujW%;j+vmaz$44LbYgZPCu8(?$SJ)s#NN4WnnLO$aZS^8 z`y7ou?{2KqkRLf{%xY#pJYuei!b}>|KTc1?ekCZ3eb>*x%L96}tKI)T@x~Im#_Bj# z;JI;U<>*XNGD7!UAh^zT#nUZ@@F`;~=D1@o&9F41`YpZ%7vHAqxQd<#2h3*n!8&*WZjHQKejFMzIKNB3sQKLw4ew7d9_{eDYi&-JTm=R~&X5cjRX&7}_~rey z^so~GimyS>HpFPEbF9!4qH*W%JqABw=q)9dPQY9r)?etZZrXUAB@M9BNWZr1zj677 zzonpL+Pfawakv1ZSo)y3VT#Q?JtaZmQ+nx}TRMr_i5)xJ^wx2{qKqigmXi7g#>X#E z#H*0UE4N}muzL1zpsme^QQNYofxo~ScO=x2lxaI(u$SC!z3HI7tQUM+a-bVvsbCqu zJ(Kq?Ma5&N=tOMAcXr`?*JQH#gwWzcYm7-Vk`Z_&s7~l9;+>83WI|_1r4^TG&gkupn1xVhdo{D35+c!*Q)vMNpgJV1v+#LoHhwE9}p@ z^!B&_2Fe4{VixCN_OgbRW)xQ9OuBXv7@oDqdt!WnqPDh`9rmuD8h)0O|5Y%T(+SgT zIq?XHnNM+a^Hd~ne_tmrYV6FgEt}2PT(@r>=$Uh3IHyHu&l0gz$|rkK3O9%_`&-r4 zzz5jW&!lfIzxJ`*1#A6WD7QeRuCkX^;RHY%)m~z#G*Xju?dXRYoMmn;RJ3L?=lXCP zeCjxarnLR>8U?zCV54)m)HVbjDT@c)u8gX$u1vIZLiMuWeJh%@hI8u3PSHU2Fjp*zq@o?K)P81*wki<_9xsAtK3dYXTl zUyl!9CJl-?^;CtLYBmd z5qapy+*3rZj2DuIa#U$UT2IeDng&)=o7EA^&=RTFj0S`Cgo-e{Uqb6# zOM?NLaPV#VybrL3-QJBQkG);)g^7up@ZZT23l#^S(wbTp2S1S0YHCx{bwPk}vZM7A zByjX?2k^REbj_TrZ837T7li~@?AMO7HSO7R7XuT+_hoIB%I6mjW8^?gHtaHNlqm&h zaiuGTc#IaJ;y8X(sGiZGSXvENpFm;y)QYnoXF*67+~6Z2TBVJBYZt5R-T^3xCq38- zmzet}`PoxS-;(BcHpZ057YYE5cuc%hZ@)!+K6R5Y%Z?h!A-+Pap|X#-52Z0s!udNP zcY-!H+5TYZS=_>E(^}g|ITa=GqoY*D9u$JyYDAb%CPYl<6@%fgndcsO*L@`AO1rwC z9VY&acpr(gP2(nXJ4I8Dg3*hjXK30VFwc4IWzk-5YQ)?irzDNE1L-xeSG&Z5D;F(F$H+}yK!3$}khditT!94if1 z_O&F4#*1@7{Rpa@0k7~7;@U%Wv#MO-9qn=a(XnM$D5Juc3%SnUd%j!cXaf$fiRgV; z7QbOUqIkwq@arZdIM{xLnXtu`5$vHLD?#Y zu+0mwAoiLPqg3~muuE54eR-~nD9xboiz)= zY7S1X^e1_AdHy#BeMp}FlxBKi5ri<+o05f;Q-6^4=`>~&6%c9ofwX^% zk52-5t$gMkzYNn`pcs71{*C>^fK|wpf<;V=T5TH-(1zUIgDEc*$W^OmUM`(@;Ro#J_tTHi|P4`_|1%Oxv2FnCFi8>jtZ9Z z8&*W__8jC()R;v>z`=UTV>?NkGR|xyZSShct@P-SUywzX7fs*K>tJ|nk{;Smutji5JR_<4L^wiBs@8URYy~HoR~)b+ zgI{6QO?4+j**Pd=SpwhN&a5<+hINhY^Bfh5<(jJ#EAN0)1SaPBa(Ni)#VhTCeJR;858_Y)5+KdN%9sSA0g^dnfA=igJ_EL)w zZ#&@~a`U%yMT+^?_0ch1fN$QMuek2XN#?m!mpAGkhcy#|OFvMmOSF0}!ZF9$3~p>$ z;#j%y+P*OLLT6$!`_GzR{H?C>1mF3NcHCsYYIiF3V;Z0xeZ`R&dCKLR0Qb8MP%!wl zE?Q8mW7Om?Jm&KQ*)_bE7=%1{mzi-39uhsl0(sR=D7z8;=+MYq9>(K|l}?Q%+YP!L zMqQxi?DCKF&6P5Ektw7~Ezx#!G6 zgYba$_K)XZloVT;;uEQ({w()LY_Wg5Y^s8o>j`q70?(&_(jjYwF#HjZlK6jl)qMRs zasC;p=#wd6v;PkuHV~$3SK+_FsOgxo;O<)XO5YX`zIl{&;o{)0<&cX`$Q4KtL>zn` zdzJRAUu@$!Eg+UV`WKdk2adtlA^SyWPafY_>^HlMw~EK^y?*Fj|CPJ~&+JG3M&Sir z3jH1t;WFg&9##T6u}8w|(*9&DgLyQlz>XqL7tj$yfcR>YY?|FAL~h)Ulz8K`Phf_P z7Fkn8f3>$S!eHX4gI{(0i%Mz`PM5h1%^f^jbHvNbr>?Fs^rm;+$pz1M-h9J>OnM3< zk5b*NVg{(RgfQi%fh2UVVf;fOAW-Y#USH@BMD#Gb#}Bt=QIUJRAU$}#yo5-Dr9kIB z(XNVLA82ewD49Bj^nNbk*$;;8a|u^~b?+~LV8wQU$u-Z<5DT{iBZJ#1n?95;TPSH= z4bls?g~M%6OiPQaAy*jURcB*qT|?kqCj)m)8_g>B3T27f3)jhUmOM@b{bXP&T^V%e zzYY*yq3b83qoL957xM}*Hxm>VVABoPRAeFJ0*crzi;a!MAY~>FCdjn4_|kGj`+YmG znB_D*1ulXRLGKqdGHM<6u_(ikl+}F?;TM!|QWDi-RQQnlj#RYx?K~@{UqS10nEM{$ zeD;dqY$kJGHA<+>J~6k1hi-$DD(^A=ip<{tJwh?%iqKJmQPTz)*oI+S^WfmG6G>zF zkl}_((&kQPrp;nG;q`KZ?iOy)?%n*R1k&)GG|HbJ6N!3^GdSf zPUmvpUix;%S8}@^{ii4=&tJ-_QtZ#x+GWH?HZS6&k{t_XMZDc@GX{4(Fqg9~V zhIH(bGvt9huB=m9Hv6Etf{hD&WY8t*-f%|psVh8}%2Ty9lM>DE8ijw#@<|Ys{6iilXZ}joU59H(&0QI2nd*v+EN`dMngScar{_6W~Q<4e% z+DpeM4@16(!*9G3%txV)XOijvim$i}o z#D6>yP~T7C7!fG1S*PF|jrx4VSJ6r4pDuBTI4z^OyU!SBd*MJBOC<5~#at;jpa%@< zQhp{C3iH_a4V>eUSxO!Gi^#?e@r57+$87(+u2}f?hlJOSblUo@Nj)5Ud}~<1@PLJF z*i;2mLvbi2dnV${aV~fAY?^0pm$g)s63<4qL(-ds4ya&cb7DSBtFP}$(arNh7g=g+ zkLv&x0z~>}1(8nnIOs?X@Q@W+?MdhiH1dEu#2!Ycj~S!OwuFtily)-~liATbVaZ7* z*-V|3vUx=Q>+p@IYb)9E`K3Id0C%hL>X90;@kjFThJLX8AQXg!PnRGzM~d8R+ri7m zJd0jl3xQjhEo@G1v|IMwV?Z&;q2WH~PJ35PFOjn4Z>($Ob5kQ7IkKQANI=+aML(qW=Bc|6R+qw=(z6G{1ky$Jcrf&op>cNXv=N( zuyN8qX9q%0LRoV!9p(z>87XRwPu$)_ fw^){Cp0o)MC64Tm4vC1!G??TDf|&fv|FihN(IvsE literal 0 HcmV?d00001 diff --git a/metadata/tr/images/phoneScreenshots/android-2.jpg b/metadata/tr/images/phoneScreenshots/android-2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b6668d2b265879359005c053c9015049e8e66837 GIT binary patch literal 183621 zcmbR|2S5`^*P9Rl8*p}|&c3T__jcYfK3vU_>Gx;WMxpMi7|7Pb3NQO`V2X zzj;TT!bjp45S;q444c6RZ$UVC}I;NoqSf*x&l%jc|kT43&M#n%L zpapROz@E5@;OwMNJn{I1KAT3Q;PfB?+(1nV{uxG`+Gj~q#u`}KK~|A|28P1CN5AI83q-Ueq4qI1&IQ}P`;BWXu*CY zQ3%{`B#Mk^fMCT5K!;nLPk<|tPP)P4lL+bol!iV_F)1B*$9uwQ0~A3lrQk#%0b7|E zK!h6M3j%gS*$)~p3JEZPE>j3U!xL&DL&dKMPzsX*4*&#Q&>apFQwpCGi9R&KtV~44 z+c20E<`H5(0V$B>ZzM`;8hXbY12G;4>W30VpDKuv00q9s<&!|g7Q;78sAQ=4^+z^p z3VO#R(Ghtj+bDP>^ZQBE;I5}l4AQ0f0*_yDB; zYWOfZ0R)4-6_LNyE(4ipbju_$dD2gU2SjW@=(FX++jyu$`)BC~GNYk*N;69Yfr{t? zGC+nGl64Yrpka!zt z<2UU8UQp3zf|#ueF$;&{%M6&UGX+RF?b8ex1@%Bzm;oCTf9IU& z8WA1#B0)kM9dYrBhd!! z0$1%Mw7T0mx;n$#4yXCYH8nf98#lzX4+{ZZpjP35XT4=RfoV7M@-#k^DEBX)qjYCv`1p7TAJ>Vh#Vpjcd3)AEdkiswpU z?~tb4rt??ZTgIZp`%*5op615e9JQ{0G_r`T(ckND*U@|_wYbbF5k^W8`iLzKcTAAj z+vS*G5dBE>BR1c;B7uEab?)J&Rfzyou zatnT)3gJZa`h2auNH;(K7>diLE7$0pjhdaX|^rUAAUw3`J~1om#+6uCRS> znW~CxL6~gHB1>6a>$(nX*`eGJF3YkuUJtep(cpkKs=?OCmmZA zGS8k6FITck$95Ksi3qFPJ&$22Y%D$~sd8yUl`i)lPj+wpz8aAn{UW zaT)E}DYd-OtV)-U*w%(tqp^eUL;i~V(9h1$Pg0SzX&s|7(y9xToctG+4vuZ{485Ao z=u_F!Ywn@c=J>%O)uds3S$mFsC{SN*2ct2C%Q4Ur4?t8{AU5b?5!rKBES_qc*Ei2d zz{P>jqVAD;)eS%rN%_(VP5mt8s1dQ1{z+3&KZsr?V%3?6jObyXhr?tp$8uZE^?>UV zy+zKl8*LS+qKpQwfu?{5_X4bJ`ugKt;!~H$xhgkT2&DGW3PxKyY5QrKo#U46m99^K z9ImfNb1EDbRr?0UTJ^i`U6=a11f^?;@w5FXyIClr?DQ!0nE*$=`c;?ML3Y7Gjik8@ z_0(Ly|0n5i2r+I{jj7}YRV=DL*plM^5qoGgdb4V@u$QCRJAJx)qCWSm z#ySNFlCV*)nnr}O#9f*}XNqHMF5^!`j)M5{t(LSCYX^I1QO)DGR4-I>B{l#-3jH+l z%+)2D`FcVexqfEqsZlZGq3^{dx0K#?dCFzjY^og#xX~7g=mF;J`6qM6*w;&ye!f2C zDb4uwIQ39ZFMmC&d~AFE*c`|CN1B$77X|f(?&xwm7n>r>PR>~%`J_zt&-NsjUN^H{ zL!7t|FL8hPS@}UjCMo}KUn&1Iu!()nMkxs9bKo-_Z4rZ-k%m+Iq=A