From 64dbf4441c8ec4d0dfcded718d1e21beebc91410 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 2 May 2026 00:50:02 +0700 Subject: [PATCH] feat: add Favorite Artists collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CollectionArtistEntry model with toJson/fromJson and artistCollectionKey helper - Create favorite_artists table in SQLite with DB migration v1→v2 - Implement toggleFavoriteArtist/removeFavoriteArtist in LibraryCollectionsNotifier - Add FavoriteArtistsScreen with list view, empty state, and artist navigation - Add heart toggle button on ArtistScreen header (reactive via Riverpod selector) - Integrate favorite artists folder in queue_tab collection grid/list views - Add 8 new localization strings across all 13 locale files --- lib/l10n/app_localizations.dart | 48 +++++ lib/l10n/app_localizations_de.dart | 37 ++++ lib/l10n/app_localizations_en.dart | 37 ++++ lib/l10n/app_localizations_es.dart | 37 ++++ lib/l10n/app_localizations_fr.dart | 37 ++++ lib/l10n/app_localizations_hi.dart | 37 ++++ lib/l10n/app_localizations_id.dart | 37 ++++ lib/l10n/app_localizations_ja.dart | 37 ++++ lib/l10n/app_localizations_ko.dart | 37 ++++ lib/l10n/app_localizations_nl.dart | 37 ++++ lib/l10n/app_localizations_pt.dart | 37 ++++ lib/l10n/app_localizations_ru.dart | 37 ++++ lib/l10n/app_localizations_tr.dart | 37 ++++ lib/l10n/app_localizations_zh.dart | 37 ++++ lib/l10n/arb/app_en.arb | 47 +++++ .../library_collections_provider.dart | 188 +++++++++++++++++ lib/screens/artist_screen.dart | 64 ++++++ lib/screens/favorite_artists_screen.dart | 197 ++++++++++++++++++ lib/screens/queue_tab.dart | 34 +++ lib/screens/queue_tab_widgets.dart | 5 +- .../library_collections_database.dart | 50 ++++- 21 files changed, 1112 insertions(+), 2 deletions(-) create mode 100644 lib/screens/favorite_artists_screen.dart diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 99ef3bda..38a188ac 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4475,6 +4475,12 @@ abstract class AppLocalizations { /// **'Loved'** String get collectionLoved; + /// Custom folder for favorite artists + /// + /// In en, this message translates to: + /// **'Favorite Artists'** + String get collectionFavoriteArtists; + /// Custom user playlists folder /// /// In en, this message translates to: @@ -4517,6 +4523,12 @@ abstract class AppLocalizations { /// **'{count, plural, =1{1 track} other{{count} tracks}}'** String collectionPlaylistTracks(int count); + /// Artist count label for favorite artists + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 artist} other{{count} artists}}'** + String collectionArtistCount(int count); + /// Snackbar after adding track to playlist /// /// In en, this message translates to: @@ -4601,6 +4613,18 @@ abstract class AppLocalizations { /// **'Tap love on tracks to keep your favorites'** String get collectionLovedEmptySubtitle; + /// Favorite artists empty state title + /// + /// In en, this message translates to: + /// **'No favorite artists yet'** + String get collectionFavoriteArtistsEmptyTitle; + + /// Favorite artists empty state subtitle + /// + /// In en, this message translates to: + /// **'Tap the heart on an artist page to keep them here'** + String get collectionFavoriteArtistsEmptySubtitle; + /// Playlist empty state title /// /// In en, this message translates to: @@ -4655,6 +4679,18 @@ abstract class AppLocalizations { /// **'\"{trackName}\" removed from Wishlist'** String collectionRemovedFromWishlist(String trackName); + /// Snackbar after adding artist to favorite artists + /// + /// In en, this message translates to: + /// **'\"{artistName}\" added to Favorite Artists'** + String collectionAddedToFavoriteArtists(String artistName); + + /// Snackbar after removing artist from favorite artists + /// + /// In en, this message translates to: + /// **'\"{artistName}\" removed from Favorite Artists'** + String collectionRemovedFromFavoriteArtists(String artistName); + /// Bottom sheet action label - add track to loved folder /// /// In en, this message translates to: @@ -4679,6 +4715,18 @@ abstract class AppLocalizations { /// **'Remove from Wishlist'** String get trackOptionRemoveFromWishlist; + /// Action label - add artist to favorite artists + /// + /// In en, this message translates to: + /// **'Add to Favorite Artists'** + String get artistOptionAddToFavorites; + + /// Action label - remove artist from favorite artists + /// + /// In en, this message translates to: + /// **'Remove from Favorite Artists'** + String get artistOptionRemoveFromFavorites; + /// Bottom sheet action to pick a custom cover image for a playlist /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 26d74ef5..1311c77a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2549,6 +2549,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get collectionLoved => 'Lieblingssongs'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlisten'; @@ -2579,6 +2582,17 @@ class AppLocalizationsDe extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Zu \"$playlistName \" hinzugefügt'; @@ -2629,6 +2643,13 @@ class AppLocalizationsDe extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tippe auf das Herz, um deine Favoriten zu behalten'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Die Playlist ist leer'; @@ -2667,6 +2688,16 @@ class AppLocalizationsDe extends AppLocalizations { return '\"$trackName\" aus der Wunschliste entfernt'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Zu Lieblingssongs hinzufügen'; @@ -2679,6 +2710,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Von der Wunschliste entfernen'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Coverbild ändern'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 7715abe4..697c05c4 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2518,6 +2518,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get collectionLoved => 'Loved'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlists'; @@ -2548,6 +2551,17 @@ class AppLocalizationsEn extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Added to \"$playlistName\"'; @@ -2598,6 +2612,13 @@ class AppLocalizationsEn extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tap love on tracks to keep your favorites'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @@ -2636,6 +2657,16 @@ class AppLocalizationsEn extends AppLocalizations { return '\"$trackName\" removed from Wishlist'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Add to Loved'; @@ -2648,6 +2679,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Change cover image'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index d5e5e86e..d8e31c90 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2518,6 +2518,9 @@ class AppLocalizationsEs extends AppLocalizations { @override String get collectionLoved => 'Loved'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlists'; @@ -2548,6 +2551,17 @@ class AppLocalizationsEs extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Added to \"$playlistName\"'; @@ -2598,6 +2612,13 @@ class AppLocalizationsEs extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tap love on tracks to keep your favorites'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @@ -2636,6 +2657,16 @@ class AppLocalizationsEs extends AppLocalizations { return '\"$trackName\" removed from Wishlist'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Add to Loved'; @@ -2648,6 +2679,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Change cover image'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 3c49607d..4cdb1ce2 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2519,6 +2519,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get collectionLoved => 'Loved'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlists'; @@ -2549,6 +2552,17 @@ class AppLocalizationsFr extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Added to \"$playlistName\"'; @@ -2599,6 +2613,13 @@ class AppLocalizationsFr extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tap love on tracks to keep your favorites'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @@ -2637,6 +2658,16 @@ class AppLocalizationsFr extends AppLocalizations { return '\"$trackName\" removed from Wishlist'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Add to Loved'; @@ -2649,6 +2680,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Change cover image'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index c6936cc5..e5ea199f 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2517,6 +2517,9 @@ class AppLocalizationsHi extends AppLocalizations { @override String get collectionLoved => 'Loved'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlists'; @@ -2547,6 +2550,17 @@ class AppLocalizationsHi extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Added to \"$playlistName\"'; @@ -2597,6 +2611,13 @@ class AppLocalizationsHi extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tap love on tracks to keep your favorites'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @@ -2635,6 +2656,16 @@ class AppLocalizationsHi extends AppLocalizations { return '\"$trackName\" removed from Wishlist'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Add to Loved'; @@ -2647,6 +2678,12 @@ class AppLocalizationsHi extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Change cover image'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index bdcd1de6..2526c3d5 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2527,6 +2527,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get collectionLoved => 'Loved'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlists'; @@ -2557,6 +2560,17 @@ class AppLocalizationsId extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Added to \"$playlistName\"'; @@ -2607,6 +2621,13 @@ class AppLocalizationsId extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tap love on tracks to keep your favorites'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @@ -2645,6 +2666,16 @@ class AppLocalizationsId extends AppLocalizations { return '\"$trackName\" removed from Wishlist'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Add to Loved'; @@ -2657,6 +2688,12 @@ class AppLocalizationsId extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Change cover image'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 0d90562c..66e4196b 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2503,6 +2503,9 @@ class AppLocalizationsJa extends AppLocalizations { @override String get collectionLoved => 'Loved'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlists'; @@ -2533,6 +2536,17 @@ class AppLocalizationsJa extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Added to \"$playlistName\"'; @@ -2583,6 +2597,13 @@ class AppLocalizationsJa extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tap love on tracks to keep your favorites'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @@ -2621,6 +2642,16 @@ class AppLocalizationsJa extends AppLocalizations { return '\"$trackName\" removed from Wishlist'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Add to Loved'; @@ -2633,6 +2664,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'ウィッシュから削除'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'カバー画像を変更'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index b51d5c10..92767abd 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2497,6 +2497,9 @@ class AppLocalizationsKo extends AppLocalizations { @override String get collectionLoved => 'Loved'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlists'; @@ -2527,6 +2530,17 @@ class AppLocalizationsKo extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Added to \"$playlistName\"'; @@ -2577,6 +2591,13 @@ class AppLocalizationsKo extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tap love on tracks to keep your favorites'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @@ -2615,6 +2636,16 @@ class AppLocalizationsKo extends AppLocalizations { return '\"$trackName\" removed from Wishlist'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Add to Loved'; @@ -2627,6 +2658,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Change cover image'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 1f705cc8..e147a4d5 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2517,6 +2517,9 @@ class AppLocalizationsNl extends AppLocalizations { @override String get collectionLoved => 'Loved'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlists'; @@ -2547,6 +2550,17 @@ class AppLocalizationsNl extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Added to \"$playlistName\"'; @@ -2597,6 +2611,13 @@ class AppLocalizationsNl extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tap love on tracks to keep your favorites'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @@ -2635,6 +2656,16 @@ class AppLocalizationsNl extends AppLocalizations { return '\"$trackName\" removed from Wishlist'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Add to Loved'; @@ -2647,6 +2678,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Change cover image'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 8a48305f..bea6de43 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2518,6 +2518,9 @@ class AppLocalizationsPt extends AppLocalizations { @override String get collectionLoved => 'Loved'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlists'; @@ -2548,6 +2551,17 @@ class AppLocalizationsPt extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Added to \"$playlistName\"'; @@ -2598,6 +2612,13 @@ class AppLocalizationsPt extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tap love on tracks to keep your favorites'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @@ -2636,6 +2657,16 @@ class AppLocalizationsPt extends AppLocalizations { return '\"$trackName\" removed from Wishlist'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Add to Loved'; @@ -2648,6 +2679,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Change cover image'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 6d9df619..6c3faa5a 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2570,6 +2570,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get collectionLoved => 'Любимые'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Плейлисты'; @@ -2602,6 +2605,17 @@ class AppLocalizationsRu extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Добавлено в \"$playlistName\"'; @@ -2652,6 +2666,13 @@ class AppLocalizationsRu extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Нажмите \"любовь\" на треках, чтобы сохранить ваши избранные'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Плейлист пуст'; @@ -2690,6 +2711,16 @@ class AppLocalizationsRu extends AppLocalizations { return '\"$trackName\" удалён из списка желаний'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Добавить в Любимое'; @@ -2702,6 +2733,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Удалить из списка желаний'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Изменить обложку'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 9ef834b5..ffd9780a 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2564,6 +2564,9 @@ class AppLocalizationsTr extends AppLocalizations { @override String get collectionLoved => 'Favoriler'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Çalma Listeleri'; @@ -2594,6 +2597,17 @@ class AppLocalizationsTr extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return '\"$playlistName\" listesine eklendi'; @@ -2645,6 +2659,13 @@ class AppLocalizationsTr extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Sevdiğiniz şarkıları burada toplamak için kalp ikonuna dokunun'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Bu çalma listesi boş'; @@ -2683,6 +2704,16 @@ class AppLocalizationsTr extends AppLocalizations { return '\"$trackName\" İstek Listenizden çıkarıldı'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Favorilere Ekle'; @@ -2695,6 +2726,12 @@ class AppLocalizationsTr extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'İstek Listesinden Çıkar'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Kapak resmini değiştir'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 437cea45..604625ad 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2518,6 +2518,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get collectionLoved => 'Loved'; + @override + String get collectionFavoriteArtists => 'Favorite Artists'; + @override String get collectionPlaylists => 'Playlists'; @@ -2548,6 +2551,17 @@ class AppLocalizationsZh extends AppLocalizations { return '$_temp0'; } + @override + String collectionArtistCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count artists', + one: '1 artist', + ); + return '$_temp0'; + } + @override String collectionAddedToPlaylist(String playlistName) { return 'Added to \"$playlistName\"'; @@ -2598,6 +2612,13 @@ class AppLocalizationsZh extends AppLocalizations { String get collectionLovedEmptySubtitle => 'Tap love on tracks to keep your favorites'; + @override + String get collectionFavoriteArtistsEmptyTitle => 'No favorite artists yet'; + + @override + String get collectionFavoriteArtistsEmptySubtitle => + 'Tap the heart on an artist page to keep them here'; + @override String get collectionPlaylistEmptyTitle => 'Playlist is empty'; @@ -2636,6 +2657,16 @@ class AppLocalizationsZh extends AppLocalizations { return '\"$trackName\" removed from Wishlist'; } + @override + String collectionAddedToFavoriteArtists(String artistName) { + return '\"$artistName\" added to Favorite Artists'; + } + + @override + String collectionRemovedFromFavoriteArtists(String artistName) { + return '\"$artistName\" removed from Favorite Artists'; + } + @override String get trackOptionAddToLoved => 'Add to Loved'; @@ -2648,6 +2679,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get trackOptionRemoveFromWishlist => 'Remove from Wishlist'; + @override + String get artistOptionAddToFavorites => 'Add to Favorite Artists'; + + @override + String get artistOptionRemoveFromFavorites => 'Remove from Favorite Artists'; + @override String get collectionPlaylistChangeCover => 'Change cover image'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 514a5597..87b5ab10 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3330,6 +3330,10 @@ "@collectionLoved": { "description": "Custom folder for favorite tracks" }, + "collectionFavoriteArtists": "Favorite Artists", + "@collectionFavoriteArtists": { + "description": "Custom folder for favorite artists" + }, "collectionPlaylists": "Playlists", "@collectionPlaylists": { "description": "Custom user playlists folder" @@ -3363,6 +3367,15 @@ } } }, + "collectionArtistCount": "{count, plural, =1{1 artist} other{{count} artists}}", + "@collectionArtistCount": { + "description": "Artist count label for favorite artists", + "placeholders": { + "count": { + "type": "int" + } + } + }, "collectionAddedToPlaylist": "Added to \"{playlistName}\"", "@collectionAddedToPlaylist": { "description": "Snackbar after adding track to playlist", @@ -3434,6 +3447,14 @@ "@collectionLovedEmptySubtitle": { "description": "Loved empty state subtitle" }, + "collectionFavoriteArtistsEmptyTitle": "No favorite artists yet", + "@collectionFavoriteArtistsEmptyTitle": { + "description": "Favorite artists empty state title" + }, + "collectionFavoriteArtistsEmptySubtitle": "Tap the heart on an artist page to keep them here", + "@collectionFavoriteArtistsEmptySubtitle": { + "description": "Favorite artists empty state subtitle" + }, "collectionPlaylistEmptyTitle": "Playlist is empty", "@collectionPlaylistEmptyTitle": { "description": "Playlist empty state title" @@ -3495,6 +3516,24 @@ } } }, + "collectionAddedToFavoriteArtists": "\"{artistName}\" added to Favorite Artists", + "@collectionAddedToFavoriteArtists": { + "description": "Snackbar after adding artist to favorite artists", + "placeholders": { + "artistName": { + "type": "String" + } + } + }, + "collectionRemovedFromFavoriteArtists": "\"{artistName}\" removed from Favorite Artists", + "@collectionRemovedFromFavoriteArtists": { + "description": "Snackbar after removing artist from favorite artists", + "placeholders": { + "artistName": { + "type": "String" + } + } + }, "trackOptionAddToLoved": "Add to Loved", "@trackOptionAddToLoved": { "description": "Bottom sheet action label - add track to loved folder" @@ -3511,6 +3550,14 @@ "@trackOptionRemoveFromWishlist": { "description": "Bottom sheet action label - remove track from wishlist" }, + "artistOptionAddToFavorites": "Add to Favorite Artists", + "@artistOptionAddToFavorites": { + "description": "Action label - add artist to favorite artists" + }, + "artistOptionRemoveFromFavorites": "Remove from Favorite Artists", + "@artistOptionRemoveFromFavorites": { + "description": "Action label - remove artist from favorite artists" + }, "collectionPlaylistChangeCover": "Change cover image", "@collectionPlaylistChangeCover": { "description": "Bottom sheet action to pick a custom cover image for a playlist" diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index 6b344228..c43f9dba 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -19,6 +19,28 @@ String trackCollectionKey(Track track) { return '$source:${track.id}'; } +String _stripCollectionResourcePrefix(String value) { + final colonIndex = value.indexOf(':'); + if (colonIndex <= 0 || colonIndex == value.length - 1) { + return value.trim(); + } + return value.substring(colonIndex + 1).trim(); +} + +String artistCollectionKey({ + required String artistId, + required String? providerId, +}) { + final trimmedArtistId = artistId.trim(); + final trimmedProviderId = providerId?.trim(); + final source = trimmedProviderId != null && trimmedProviderId.isNotEmpty + ? trimmedProviderId.toLowerCase() + : (trimmedArtistId.contains(':') + ? trimmedArtistId.split(':').first.toLowerCase() + : 'builtin'); + return '$source:${_stripCollectionResourcePrefix(trimmedArtistId)}'; +} + class CollectionTrackEntry { final String key; final Track track; @@ -46,6 +68,49 @@ class CollectionTrackEntry { } } +class CollectionArtistEntry { + final String key; + final String artistId; + final String? providerId; + final String name; + final String? imageUrl; + final DateTime addedAt; + + const CollectionArtistEntry({ + required this.key, + required this.artistId, + required this.providerId, + required this.name, + this.imageUrl, + required this.addedAt, + }); + + Map toJson() => { + 'key': key, + 'artistId': artistId, + 'providerId': providerId, + 'name': name, + 'imageUrl': imageUrl, + 'addedAt': addedAt.toIso8601String(), + }; + + factory CollectionArtistEntry.fromJson(Map json) { + final artistId = json['artistId'] as String; + final providerId = json['providerId'] as String?; + final addedAtRaw = json['addedAt'] as String?; + return CollectionArtistEntry( + key: + json['key'] as String? ?? + artistCollectionKey(artistId: artistId, providerId: providerId), + artistId: artistId, + providerId: providerId, + name: json['name'] as String? ?? '', + imageUrl: json['imageUrl'] as String?, + addedAt: DateTime.tryParse(addedAtRaw ?? '') ?? DateTime.now(), + ); + } +} + class UserPlaylistCollection { final String id; final String name; @@ -180,9 +245,11 @@ class LibraryCollectionsState { final List wishlist; final List loved; final List playlists; + final List favoriteArtists; final bool isLoaded; final Set _wishlistKeys; final Set _lovedKeys; + final Set _favoriteArtistKeys; final Map _playlistsById; final Set _allPlaylistTrackKeys; @@ -190,14 +257,19 @@ class LibraryCollectionsState { this.wishlist = const [], this.loved = const [], this.playlists = const [], + this.favoriteArtists = const [], this.isLoaded = false, Set? wishlistKeys, Set? lovedKeys, + Set? favoriteArtistKeys, Map? playlistsById, Set? allPlaylistTrackKeys, }) : _wishlistKeys = wishlistKeys ?? wishlist.map((entry) => entry.key).toSet(), _lovedKeys = lovedKeys ?? loved.map((entry) => entry.key).toSet(), + _favoriteArtistKeys = + favoriteArtistKeys ?? + favoriteArtists.map((entry) => entry.key).toSet(), _playlistsById = playlistsById ?? Map.fromEntries( @@ -209,6 +281,7 @@ class LibraryCollectionsState { int get wishlistCount => wishlist.length; int get lovedCount => loved.length; int get playlistCount => playlists.length; + int get favoriteArtistCount => favoriteArtists.length; bool isInWishlist(Track track) { final key = trackCollectionKey(track); @@ -228,6 +301,18 @@ class LibraryCollectionsState { return _lovedKeys.contains(trackKey); } + bool isFavoriteArtist({ + required String artistId, + required String? providerId, + }) { + final key = artistCollectionKey(artistId: artistId, providerId: providerId); + return _favoriteArtistKeys.contains(key); + } + + bool containsFavoriteArtistKey(String artistKey) { + return _favoriteArtistKeys.contains(artistKey); + } + UserPlaylistCollection? playlistById(String playlistId) { return _playlistsById[playlistId]; } @@ -248,22 +333,30 @@ class LibraryCollectionsState { List? wishlist, List? loved, List? playlists, + List? favoriteArtists, bool? isLoaded, }) { final nextWishlist = wishlist ?? this.wishlist; final nextLoved = loved ?? this.loved; final nextPlaylists = playlists ?? this.playlists; + final nextFavoriteArtists = favoriteArtists ?? this.favoriteArtists; final keepWishlistIndex = identical(nextWishlist, this.wishlist); final keepLovedIndex = identical(nextLoved, this.loved); final keepPlaylistIndex = identical(nextPlaylists, this.playlists); + final keepFavoriteArtistIndex = identical( + nextFavoriteArtists, + this.favoriteArtists, + ); return LibraryCollectionsState( wishlist: nextWishlist, loved: nextLoved, playlists: nextPlaylists, + favoriteArtists: nextFavoriteArtists, isLoaded: isLoaded ?? this.isLoaded, wishlistKeys: keepWishlistIndex ? _wishlistKeys : null, lovedKeys: keepLovedIndex ? _lovedKeys : null, + favoriteArtistKeys: keepFavoriteArtistIndex ? _favoriteArtistKeys : null, playlistsById: keepPlaylistIndex ? _playlistsById : null, allPlaylistTrackKeys: keepPlaylistIndex ? _allPlaylistTrackKeys : null, ); @@ -273,12 +366,14 @@ class LibraryCollectionsState { 'wishlist': wishlist.map((e) => e.toJson()).toList(), 'loved': loved.map((e) => e.toJson()).toList(), 'playlists': playlists.map((e) => e.toJson()).toList(), + 'favoriteArtists': favoriteArtists.map((e) => e.toJson()).toList(), }; factory LibraryCollectionsState.fromJson(Map json) { final wishlistRaw = (json['wishlist'] as List?) ?? const []; final lovedRaw = (json['loved'] as List?) ?? const []; final playlistsRaw = (json['playlists'] as List?) ?? const []; + final favoriteArtistsRaw = (json['favoriteArtists'] as List?) ?? const []; return LibraryCollectionsState( wishlist: wishlistRaw @@ -300,6 +395,12 @@ class LibraryCollectionsState { UserPlaylistCollection.fromJson(Map.from(e)), ) .toList(growable: false), + favoriteArtists: favoriteArtistsRaw + .whereType>() + .map( + (e) => CollectionArtistEntry.fromJson(Map.from(e)), + ) + .toList(growable: false), isLoaded: true, ); } @@ -360,6 +461,14 @@ class LibraryCollectionsNotifier extends Notifier { } } + final favoriteArtists = []; + for (final row in snapshot.favoriteArtistRows) { + final parsed = _parseArtistEntryRow(row); + if (parsed != null) { + favoriteArtists.add(parsed); + } + } + final tracksByPlaylist = >{}; for (final row in snapshot.playlistTrackRows) { final playlistId = row['playlist_id'] as String?; @@ -396,6 +505,7 @@ class LibraryCollectionsNotifier extends Notifier { wishlist: wishlist, loved: loved, playlists: playlists, + favoriteArtists: favoriteArtists, isLoaded: true, ); } catch (_) { @@ -430,6 +540,31 @@ class LibraryCollectionsNotifier extends Notifier { } } + CollectionArtistEntry? _parseArtistEntryRow(Map row) { + final key = row['artist_key'] as String?; + final artistJson = row['artist_json'] as String?; + if (key == null || + key.isEmpty || + artistJson == null || + artistJson.isEmpty) { + return null; + } + + try { + final decoded = jsonDecode(artistJson); + if (decoded is! Map) return null; + final map = Map.from(decoded); + final addedAtRaw = row['added_at'] as String?; + return CollectionArtistEntry.fromJson({ + ...map, + 'key': key, + 'addedAt': map['addedAt'] ?? addedAtRaw, + }); + } catch (_) { + return null; + } + } + bool _replacePlaylistById( String playlistId, UserPlaylistCollection Function(UserPlaylistCollection playlist) update, @@ -503,6 +638,59 @@ class LibraryCollectionsNotifier extends Notifier { return true; } + Future toggleFavoriteArtist({ + required String artistId, + required String? providerId, + required String name, + String? imageUrl, + }) async { + await _ensureLoaded(); + final key = artistCollectionKey(artistId: artistId, providerId: providerId); + final sourceSeparator = key.indexOf(':'); + final source = sourceSeparator > 0 ? key.substring(0, sourceSeparator) : ''; + final trimmedProviderId = providerId?.trim(); + final effectiveProviderId = + trimmedProviderId != null && trimmedProviderId.isNotEmpty + ? trimmedProviderId + : (source.isNotEmpty && source != 'builtin' ? source : null); + if (state.containsFavoriteArtistKey(key)) { + await _db.deleteFavoriteArtistEntry(key); + final updated = state.favoriteArtists + .where((entry) => entry.key != key) + .toList(growable: false); + state = state.copyWith(favoriteArtists: updated); + return false; + } + + final entry = CollectionArtistEntry( + key: key, + artistId: _stripCollectionResourcePrefix(artistId), + providerId: effectiveProviderId, + name: name, + imageUrl: imageUrl, + addedAt: DateTime.now(), + ); + await _db.upsertFavoriteArtistEntry( + artistKey: key, + artistJson: jsonEncode(entry.toJson()), + addedAt: entry.addedAt.toIso8601String(), + ); + final updated = [entry, ...state.favoriteArtists]; + state = state.copyWith(favoriteArtists: updated); + return true; + } + + Future removeFavoriteArtist(String artistKey) async { + await _ensureLoaded(); + if (!state.containsFavoriteArtistKey(artistKey)) return; + + await _db.deleteFavoriteArtistEntry(artistKey); + final updated = state.favoriteArtists + .where((entry) => entry.key != artistKey) + .toList(growable: false); + state = state.copyWith(favoriteArtists: updated); + } + Future removeFromWishlist(String trackKey) async { await _ensureLoaded(); if (!state.containsWishlistKey(trackKey)) return; diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 196ebcf1..b534e8d2 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -10,6 +10,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/recent_access_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; @@ -912,6 +913,32 @@ class _ArtistScreenState extends ConsumerState { await _downloadAlbums(context, albums); } + Future _toggleFavoriteArtist(BuildContext context) async { + final providerId = _directMetadataProviderId(); + final imageUrl = + _headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl; + final added = await ref + .read(libraryCollectionsProvider.notifier) + .toggleFavoriteArtist( + artistId: _metadataResourceId(providerId ?? ''), + providerId: providerId, + name: widget.artistName, + imageUrl: imageUrl, + ); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + added + ? context.l10n.collectionAddedToFavoriteArtists(widget.artistName) + : context.l10n.collectionRemovedFromFavoriteArtists( + widget.artistName, + ), + ), + ), + ); + } + Future _fetchAndQueueAlbums( List albums, String service, @@ -1100,6 +1127,17 @@ class _ArtistScreenState extends ConsumerState { ); } + final favoriteProviderId = _directMetadataProviderId(); + final favoriteArtistId = _metadataResourceId(favoriteProviderId ?? ''); + final isFavoriteArtist = ref.watch( + libraryCollectionsProvider.select( + (state) => state.isFavoriteArtist( + artistId: favoriteArtistId, + providerId: favoriteProviderId, + ), + ), + ); + return SliverAppBar( expandedHeight: hasDiscography ? 420 : 380, pinned: true, @@ -1220,6 +1258,32 @@ class _ArtistScreenState extends ConsumerState { ], ), ), + if (!_isSelectionMode) ...[ + const SizedBox(width: 12), + Container( + width: 52, + height: 52, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: IconButton( + onPressed: () => _toggleFavoriteArtist(context), + icon: Icon( + isFavoriteArtist + ? Icons.favorite + : Icons.favorite_border, + size: 26, + ), + color: isFavoriteArtist + ? colorScheme.error + : Colors.black87, + tooltip: isFavoriteArtist + ? context.l10n.artistOptionRemoveFromFavorites + : context.l10n.artistOptionAddToFavorites, + ), + ), + ], if (hasDiscography && !_isSelectionMode) ...[ const SizedBox(width: 12), Container( diff --git a/lib/screens/favorite_artists_screen.dart b/lib/screens/favorite_artists_screen.dart new file mode 100644 index 00000000..63f3c5ec --- /dev/null +++ b/lib/screens/favorite_artists_screen.dart @@ -0,0 +1,197 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/screens/artist_screen.dart'; +import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/utils/app_bar_layout.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; + +class FavoriteArtistsScreen extends ConsumerWidget { + const FavoriteArtistsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final artists = ref.watch( + libraryCollectionsProvider.select((state) => state.favoriteArtists), + ); + final colorScheme = Theme.of(context).colorScheme; + final topPadding = normalizedHeaderTopPadding(context); + + return Scaffold( + body: CustomScrollView( + slivers: [ + SliverAppBar( + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + title: Text( + context.l10n.collectionFavoriteArtists, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), + ), + if (artists.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.favorite, + size: 60, + color: colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 12), + Text( + context.l10n.collectionFavoriteArtistsEmptyTitle, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + context.l10n.collectionFavoriteArtistsEmptySubtitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ) + else + SliverList.separated( + itemCount: artists.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) { + final artist = artists[index]; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + leading: _ArtistThumbnail(artist: artist), + title: Text( + artist.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: + artist.providerId == null || artist.providerId!.isEmpty + ? null + : Text( + artist.providerId!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + trailing: IconButton( + tooltip: context.l10n.artistOptionRemoveFromFavorites, + icon: Icon(Icons.favorite, color: colorScheme.error), + onPressed: () async { + await ref + .read(libraryCollectionsProvider.notifier) + .removeFavoriteArtist(artist.key); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.collectionRemovedFromFavoriteArtists( + artist.name, + ), + ), + ), + ); + }, + ), + onTap: () { + Navigator.of(context).push( + slidePageRoute( + page: ArtistScreen( + artistId: artist.artistId, + artistName: artist.name, + coverUrl: artist.imageUrl, + extensionId: + artist.providerId != null && + artist.providerId!.isNotEmpty + ? artist.providerId + : null, + ), + ), + ); + }, + ); + }, + ), + ], + ), + ); + } +} + +class _ArtistThumbnail extends StatelessWidget { + final CollectionArtistEntry artist; + + const _ArtistThumbnail({required this.artist}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final imageUrl = artist.imageUrl; + return ClipOval( + child: imageUrl != null && imageUrl.isNotEmpty + ? CachedNetworkImage( + imageUrl: imageUrl, + width: 56, + height: 56, + fit: BoxFit.cover, + memCacheWidth: 112, + memCacheHeight: 112, + cacheManager: CoverCacheManager.instance, + errorWidget: (_, _, _) => _placeholder(colorScheme), + ) + : _placeholder(colorScheme), + ); + } + + Widget _placeholder(ColorScheme colorScheme) { + return Container( + width: 56, + height: 56, + color: colorScheme.surfaceContainerHighest, + child: Icon(Icons.person, color: colorScheme.onSurfaceVariant), + ); + } +} diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 068e5933..65d2935c 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -27,6 +27,7 @@ import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/screens/favorite_artists_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/re_enrich_field_dialog.dart'; import 'package:spotiflac_android/widgets/batch_progress_dialog.dart'; @@ -2029,6 +2030,12 @@ class _QueueTabState extends ConsumerState { ); } + void _openFavoriteArtistsFolder() { + _navigateWithUnfocus( + MaterialPageRoute(builder: (_) => const FavoriteArtistsScreen()), + ); + } + void _openPlaylistById(String playlistId) { _navigateWithUnfocus( MaterialPageRoute( @@ -2315,6 +2322,7 @@ class _QueueTabState extends ConsumerState { (s) => ( s.wishlistCount, s.lovedCount, + s.favoriteArtistCount, s.playlistCount, s.hasPlaylistTracks, s.isLoaded, @@ -2932,6 +2940,9 @@ class _QueueTabState extends ConsumerState { if (collectionState.lovedCount > 0) { entries.add(_CollectionEntry.loved); } + if (collectionState.favoriteArtistCount > 0) { + entries.add(_CollectionEntry.favoriteArtists); + } for (var i = 0; i < collectionState.playlists.length; i++) { entries.add(_CollectionEntry.playlist(i)); } @@ -2968,6 +2979,17 @@ class _QueueTabState extends ConsumerState { count: collectionState.lovedCount, onTap: _openLovedFolder, ); + case _CollectionEntryType.favoriteArtists: + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.person, + iconColor: Colors.white, + iconBgColor: const Color(0xFFE91E63), + title: context.l10n.collectionFavoriteArtists, + count: collectionState.favoriteArtistCount, + onTap: _openFavoriteArtistsFolder, + ); case _CollectionEntryType.playlist: final playlist = collectionState.playlists[entry.playlistIndex]; final isSelected = _selectedPlaylistIds.contains(playlist.id); @@ -3087,6 +3109,18 @@ class _QueueTabState extends ConsumerState { '${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}', onTap: _openLovedFolder, ); + case _CollectionEntryType.favoriteArtists: + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.person, + iconColor: Colors.white, + iconBgColor: const Color(0xFFE91E63), + title: context.l10n.collectionFavoriteArtists, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${context.l10n.collectionArtistCount(collectionState.favoriteArtistCount)}', + onTap: _openFavoriteArtistsFolder, + ); case _CollectionEntryType.playlist: final playlist = collectionState.playlists[entry.playlistIndex]; final isSelected = _selectedPlaylistIds.contains(playlist.id); diff --git a/lib/screens/queue_tab_widgets.dart b/lib/screens/queue_tab_widgets.dart index e53aac52..47169ef6 100644 --- a/lib/screens/queue_tab_widgets.dart +++ b/lib/screens/queue_tab_widgets.dart @@ -25,7 +25,7 @@ class _QueueItemSliverRow extends ConsumerWidget { } } -enum _CollectionEntryType { wishlist, loved, playlist } +enum _CollectionEntryType { wishlist, loved, favoriteArtists, playlist } class _CollectionEntry { final _CollectionEntryType type; @@ -35,6 +35,9 @@ class _CollectionEntry { static const wishlist = _CollectionEntry._(_CollectionEntryType.wishlist); static const loved = _CollectionEntry._(_CollectionEntryType.loved); + static const favoriteArtists = _CollectionEntry._( + _CollectionEntryType.favoriteArtists, + ); static _CollectionEntry playlist(int index) => _CollectionEntry._(_CollectionEntryType.playlist, index); } diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index d6af1c05..e77b0541 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -9,12 +9,13 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('LibraryCollectionsDb'); const _dbFileName = 'library_collections.db'; -const _dbVersion = 1; +const _dbVersion = 2; const _tableWishlist = 'wishlist_tracks'; const _tableLoved = 'loved_tracks'; const _tablePlaylists = 'playlists'; const _tablePlaylistTracks = 'playlist_tracks'; +const _tableFavoriteArtists = 'favorite_artists'; const _legacyCollectionsStorageKey = 'library_collections_v1'; const _migrationDoneKey = 'library_collections_migrated_to_sqlite_v1'; @@ -24,12 +25,14 @@ class LibraryCollectionsSnapshot { final List> lovedRows; final List> playlistRows; final List> playlistTrackRows; + final List> favoriteArtistRows; const LibraryCollectionsSnapshot({ required this.wishlistRows, required this.lovedRows, required this.playlistRows, required this.playlistTrackRows, + required this.favoriteArtistRows, }); } @@ -129,6 +132,8 @@ class LibraryCollectionsDatabase { ) '''); + await _createFavoriteArtistsTable(db); + await db.execute( 'CREATE INDEX idx_${_tableWishlist}_added_at ON $_tableWishlist(added_at DESC)', ); @@ -148,6 +153,22 @@ class LibraryCollectionsDatabase { Future _upgradeDb(Database db, int oldVersion, int newVersion) async { _log.i('Upgrading collections database from v$oldVersion to v$newVersion'); + if (oldVersion < 2) { + await _createFavoriteArtistsTable(db); + } + } + + Future _createFavoriteArtistsTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS $_tableFavoriteArtists ( + artist_key TEXT PRIMARY KEY, + artist_json TEXT NOT NULL, + added_at TEXT NOT NULL + ) + '''); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_${_tableFavoriteArtists}_added_at ON $_tableFavoriteArtists(added_at DESC)', + ); } Future migrateFromSharedPreferences() async { @@ -264,12 +285,17 @@ class LibraryCollectionsDatabase { _tablePlaylistTracks, orderBy: 'playlist_id ASC, added_at DESC, rowid DESC', ); + final favoriteArtistRows = await db.query( + _tableFavoriteArtists, + orderBy: 'added_at DESC, rowid DESC', + ); return LibraryCollectionsSnapshot( wishlistRows: wishlistRows, lovedRows: lovedRows, playlistRows: playlistRows, playlistTrackRows: playlistTrackRows, + favoriteArtistRows: favoriteArtistRows, ); } @@ -426,6 +452,28 @@ class LibraryCollectionsDatabase { await db.delete(_tableLoved, where: 'track_key = ?', whereArgs: [trackKey]); } + Future upsertFavoriteArtistEntry({ + required String artistKey, + required String artistJson, + required String addedAt, + }) async { + final db = await database; + await db.insert(_tableFavoriteArtists, { + 'artist_key': artistKey, + 'artist_json': artistJson, + 'added_at': addedAt, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + + Future deleteFavoriteArtistEntry(String artistKey) async { + final db = await database; + await db.delete( + _tableFavoriteArtists, + where: 'artist_key = ?', + whereArgs: [artistKey], + ); + } + Future upsertPlaylist({ required String id, required String name,