diff --git a/lib/components/artist/artist_card.dart b/lib/components/artist/artist_card.dart index 22bfc04a4..e0bc0dc22 100644 --- a/lib/components/artist/artist_card.dart +++ b/lib/components/artist/artist_card.dart @@ -1,27 +1,35 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:fluent_ui/fluent_ui.dart' hide Colors; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:platform_ui/platform_ui.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/hooks/use_platform_property.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 HookWidget { +class ArtistCard extends HookConsumerWidget { final Artist artist; const ArtistCard(this.artist, {Key? key}) : super(key: key); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, ref) { final backgroundImage = UniversalImage.imageProvider( TypeConversionUtils.image_X_UrlString( artist.images, placeholder: ImagePlaceholder.artist, ), ); + final isBlackListed = ref.watch( + BlackListNotifier.provider.select( + (blacklist) => blacklist.contains( + BlacklistedElement.artist(artist.id!, artist.name!), + ), + ), + ); final boxShadow = usePlatformProperty( (context) => PlatformProperty( android: BoxShadow( @@ -78,14 +86,19 @@ class ArtistCard extends HookWidget { boxShadow: [ if (boxShadow != null) boxShadow, ], - border: [TargetPlatform.windows, TargetPlatform.macOS] - .contains(platform) + border: isBlackListed ? Border.all( - color: PlatformTheme.of(context).borderColor ?? - Colors.transparent, - width: 1, + color: Colors.red[400]!, + width: 2, ) - : null, + : [TargetPlatform.windows, TargetPlatform.macOS] + .contains(platform) + ? Border.all( + color: PlatformTheme.of(context).borderColor ?? + Colors.transparent, + width: 1, + ) + : null, ), child: Padding( padding: const EdgeInsets.all(15), diff --git a/lib/components/settings/blacklist_dialog.dart b/lib/components/settings/blacklist_dialog.dart new file mode 100644 index 000000000..a1afdf5ef --- /dev/null +++ b/lib/components/settings/blacklist_dialog.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:collection/collection.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:platform_ui/platform_ui.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:tuple/tuple.dart'; + +class BlackListDialog extends HookConsumerWidget { + const BlackListDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final blacklist = ref.watch(BlackListNotifier.provider); + final searchText = useState(""); + + final filteredBlacklist = useMemoized( + () { + if (searchText.value.isEmpty) { + return blacklist; + } + return blacklist + .map((e) => Tuple2( + weightedRatio("${e.name} ${e.type.name}", searchText.value), + e, + )) + .sorted((a, b) => b.item1.compareTo(a.item1)) + .where((e) => e.item1 > 50) + .map((e) => e.item2) + .toList(); + }, + [blacklist, searchText.value], + ); + + return PlatformAlertDialog( + title: const PlatformText("Blacklist"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: PlatformTextField( + onChanged: (value) => searchText.value = value, + placeholder: "Search", + prefixIcon: Icons.search_rounded, + ), + ), + ListView.builder( + shrinkWrap: true, + itemCount: filteredBlacklist.length, + itemBuilder: (context, index) { + final item = filteredBlacklist.elementAt(index); + return ListTile( + leading: PlatformText("${index + 1}."), + minLeadingWidth: 20, + title: PlatformText("${item.name} (${item.type.name})"), + subtitle: PlatformText.caption(item.id), + trailing: IconButton( + icon: Icon(Icons.delete_forever_rounded, + color: Colors.red[400]), + onPressed: () { + ref + .read(BlackListNotifier.provider.notifier) + .remove(filteredBlacklist.elementAt(index)); + }, + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart index a94b585ce..f194ded5a 100644 --- a/lib/components/shared/adaptive/adaptive_popup_menu_button.dart +++ b/lib/components/shared/adaptive/adaptive_popup_menu_button.dart @@ -9,12 +9,14 @@ class Action extends StatelessWidget { final Widget icon; final void Function() onPressed; final bool isExpanded; + final Color? backgroundColor; const Action({ Key? key, required this.icon, required this.text, required this.onPressed, this.isExpanded = true, + this.backgroundColor, }) : super(key: key); @override @@ -23,6 +25,7 @@ class Action extends StatelessWidget { return PlatformIconButton( icon: icon, onPressed: onPressed, + backgroundColor: backgroundColor, tooltip: text is PlatformText ? (text as PlatformText).data : text.toStringShallow().split(",").last.replaceAll( @@ -31,18 +34,42 @@ class Action extends StatelessWidget { ), ); } - return PlatformTextButton( - style: TextButton.styleFrom( - foregroundColor: PlatformTextTheme.of(context).body?.color, - padding: const EdgeInsets.all(20), - ), - onPressed: onPressed, - child: Row( - children: [ - icon, - const SizedBox(width: 10), - text, - ], + if (backgroundColor == null) { + return PlatformTextButton( + style: TextButton.styleFrom( + foregroundColor: + backgroundColor ?? PlatformTextTheme.of(context).body?.color, + backgroundColor: backgroundColor, + padding: const EdgeInsets.all(20), + ), + onPressed: onPressed, + child: Row( + children: [ + icon, + const SizedBox(width: 10), + text, + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(8.0), + child: PlatformFilledButton( + style: TextButton.styleFrom( + foregroundColor: + backgroundColor ?? PlatformTextTheme.of(context).body?.color, + backgroundColor: backgroundColor, + padding: const EdgeInsets.all(20), + ), + onPressed: onPressed, + child: Row( + children: [ + icon, + const SizedBox(width: 10), + text, + ], + ), ), ); } @@ -75,7 +102,7 @@ class AdaptiveActions extends HookWidget { children: actions .map( (action) => SizedBox( - width: 200, + width: 250, child: Row( children: [ Expanded(child: action), @@ -99,6 +126,7 @@ class AdaptiveActions extends HookWidget { icon: action.icon, onPressed: action.onPressed, text: action.text, + backgroundColor: action.backgroundColor, isExpanded: false, ); }).toList(), diff --git a/lib/components/shared/track_table/track_tile.dart b/lib/components/shared/track_table/track_tile.dart index 51577dfdb..ab9c8b42f 100644 --- a/lib/components/shared/track_table/track_tile.dart +++ b/lib/components/shared/track_table/track_tile.dart @@ -15,6 +15,7 @@ import 'package:spotube/components/root/sidebar.dart'; import 'package:spotube/hooks/use_breakpoints.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/auth_provider.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/playback_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/mutations/mutations.dart'; @@ -62,6 +63,13 @@ class TrackTile extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final breakpoint = useBreakpoints(); + final isBlackListed = ref.watch( + BlackListNotifier.provider.select( + (blacklist) => blacklist.contains( + BlacklistedElement.track(track.value.id!, track.value.name!), + ), + ), + ); final auth = ref.watch(authProvider); final spotify = ref.watch(spotifyProvider); final removingTrack = useState(null); @@ -179,9 +187,11 @@ class TrackTile extends HookConsumerWidget { return AnimatedContainer( duration: const Duration(milliseconds: 500), decoration: BoxDecoration( - color: isActive - ? Theme.of(context).popupMenuTheme.color - : Colors.transparent, + color: isBlackListed + ? Colors.red[100] + : isActive + ? Theme.of(context).popupMenuTheme.color + : Colors.transparent, borderRadius: BorderRadius.circular(isActive ? 10 : 0), ), child: Material( @@ -238,22 +248,43 @@ class TrackTile extends HookConsumerWidget { backgroundColor: PlatformTheme.of(context).primaryColor, hoverColor: PlatformTheme.of(context).primaryColor?.withOpacity(0.5), - onPressed: () => onTrackPlayButtonPressed?.call( - track.value, - ), + onPressed: !isBlackListed + ? () => onTrackPlayButtonPressed?.call( + track.value, + ) + : null, ), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - PlatformText( - track.value.name ?? "", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: breakpoint.isSm ? 14 : 17, - ), - overflow: TextOverflow.ellipsis, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: PlatformText( + track.value.name ?? "", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: breakpoint.isSm ? 14 : 17, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isBlackListed) ...[ + const SizedBox(width: 5), + PlatformText( + "Blacklisted", + style: TextStyle( + color: Colors.red[400], + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ] + ], ), isLocal ? PlatformText( @@ -327,6 +358,32 @@ class TrackTile extends HookConsumerWidget { onPressed: () { actionShare(track.value); }, + ), + Action( + icon: Icon( + Icons.playlist_remove_rounded, + color: isBlackListed ? Colors.white : Colors.red[400], + ), + backgroundColor: isBlackListed ? Colors.red[400] : null, + text: PlatformText( + "${isBlackListed ? "Remove from" : "Add to"} blacklist", + style: TextStyle( + color: isBlackListed ? Colors.white : Colors.red[400], + ), + ), + onPressed: () { + if (isBlackListed) { + ref.read(BlackListNotifier.provider.notifier).remove( + BlacklistedElement.track( + track.value.id!, track.value.name!), + ); + } else { + ref.read(BlackListNotifier.provider.notifier).add( + BlacklistedElement.track( + track.value.id!, track.value.name!), + ); + } + }, ) ], ), diff --git a/lib/components/shared/track_table/tracks_table_view.dart b/lib/components/shared/track_table/tracks_table_view.dart index 74a390b8d..56afce78d 100644 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ b/lib/components/shared/track_table/tracks_table_view.dart @@ -10,6 +10,7 @@ import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/components/library/user_local_tracks.dart'; import 'package:spotube/hooks/use_breakpoints.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/downloader_provider.dart'; import 'package:spotube/provider/playback_provider.dart'; import 'package:spotube/utils/primitive_utils.dart'; @@ -217,7 +218,17 @@ class TracksTableView extends HookConsumerWidget { selected.value = [...selected.value, track.value.id!]; } } else { - onTrackPlayButtonPressed?.call(track.value); + final isBlackListed = ref.read( + BlackListNotifier.provider.select( + (blacklist) => blacklist.contains( + BlacklistedElement.track( + track.value.id!, track.value.name!), + ), + ), + ); + if (!isBlackListed) { + onTrackPlayButtonPressed?.call(track.value); + } } }, child: TrackTile( diff --git a/lib/pages/artist/artist.dart b/lib/pages/artist/artist.dart index 15c12606d..a97337c97 100644 --- a/lib/pages/artist/artist.dart +++ b/lib/pages/artist/artist.dart @@ -17,6 +17,7 @@ import 'package:spotube/hooks/use_breakpoints.dart'; import 'package:spotube/models/current_playlist.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/provider/auth_provider.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/playback_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -78,6 +79,11 @@ class ArtistPage extends HookConsumerWidget { final data = artistsQuery.data!; + final blacklist = ref.watch(BlackListNotifier.provider); + final isBlackListed = blacklist.contains( + BlacklistedElement.artist(artistId, data.name!), + ); + return SingleChildScrollView( controller: parentScrollController, padding: const EdgeInsets.all(20), @@ -104,15 +110,40 @@ class ArtistPage extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(50)), - child: PlatformText(data.type!.toUpperCase(), - style: chipTextVariant?.copyWith( - color: Colors.white)), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(50)), + child: PlatformText( + data.type!.toUpperCase(), + style: chipTextVariant?.copyWith( + color: Colors.white, + ), + ), + ), + if (isBlackListed) ...[ + const SizedBox(width: 5), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.red[400], + borderRadius: + BorderRadius.circular(50)), + child: PlatformText( + "Blacklisted", + style: chipTextVariant?.copyWith( + color: Colors.white, + ), + ), + ), + ] + ], ), PlatformText( data.name!, @@ -149,6 +180,8 @@ class ArtistPage extends HookConsumerWidget { ); } + final queryBowl = QueryBowl.of(context); + return PlatformFilledButton( onPressed: () async { try { @@ -162,7 +195,8 @@ class ArtistPage extends HookConsumerWidget { [artistId], ); await isFollowingQuery.refetch(); - QueryBowl.of(context) + + queryBowl .getInfiniteQuery( Queries.artist.followedByMe .queryKey, @@ -191,25 +225,55 @@ class ArtistPage extends HookConsumerWidget { ); }, ), + const SizedBox(width: 5), + PlatformIconButton( + tooltip: "Add to blacklisted artists", + icon: Icon( + Icons.person_remove_rounded, + color: !isBlackListed + ? Colors.red[400] + : Colors.white, + ), + backgroundColor: + isBlackListed ? Colors.red[400] : null, + onPressed: () async { + if (isBlackListed) { + ref + .read(BlackListNotifier + .provider.notifier) + .remove( + BlacklistedElement.artist( + data.id!, data.name!), + ); + } else { + ref + .read(BlackListNotifier + .provider.notifier) + .add( + BlacklistedElement.artist( + data.id!, data.name!), + ); + } + }, + ), PlatformIconButton( icon: const Icon(Icons.share_rounded), - onPressed: () { - Clipboard.setData( + onPressed: () async { + await Clipboard.setData( ClipboardData( text: data.externalUrls?.spotify), - ).then((val) { - ScaffoldMessenger.of(context) - .showSnackBar( - const SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: PlatformText( - "Artist URL copied to clipboard", - textAlign: TextAlign.center, - ), + ); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: PlatformText( + "Artist URL copied to clipboard", + textAlign: TextAlign.center, ), - ); - }); + ), + ); }, ) ], diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 76b70e4eb..8a4031477 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -5,6 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:platform_ui/platform_ui.dart'; +import 'package:spotube/components/settings/blacklist_dialog.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/components/shared/adaptive/adaptive_list_tile.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; @@ -12,7 +13,6 @@ import 'package:spotube/hooks/use_breakpoints.dart'; import 'package:spotube/main.dart'; import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/models/spotube_track.dart'; -import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/provider/auth_provider.dart'; import 'package:spotube/provider/playback_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; @@ -337,6 +337,22 @@ class SettingsPage extends HookConsumerWidget { }, ), ), + PlatformListTile( + leading: const Icon(Icons.playlist_remove_rounded), + title: const PlatformText( + "Track/Artist Blacklist", + ), + onTap: () { + showPlatformAlertDialog( + context, + barrierDismissible: true, + builder: (context) { + return const BlackListDialog(); + }, + ); + }, + trailing: const Icon(Icons.open_in_new_rounded), + ), PlatformText( " Search", style: PlatformTextTheme.of(context) diff --git a/lib/provider/blacklist_provider.dart b/lib/provider/blacklist_provider.dart new file mode 100644 index 000000000..21bca2337 --- /dev/null +++ b/lib/provider/blacklist_provider.dart @@ -0,0 +1,87 @@ +import 'package:flutter_riverpod/flutter_riverpod.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}; + + @override + operator ==(other) => + other is BlacklistedElement && + other.id == id && + other.type == type && + other.name == name; + + @override + int get hashCode => id.hashCode ^ type.hashCode ^ name.hashCode; +} + +class BlackListNotifier + extends PersistedStateNotifier> { + BlackListNotifier() : super({}); + + static final provider = + StateNotifierProvider>( + (ref) => BlackListNotifier(), + ); + + void add(BlacklistedElement element) { + state = state.union({element}); + } + + void remove(BlacklistedElement element) { + state = state.difference({element}); + } + + CurrentPlaylist filterPlaylist(CurrentPlaylist playlist) { + return CurrentPlaylist( + 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(), + ); + } + + @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()}; + } +} diff --git a/lib/provider/playback_provider.dart b/lib/provider/playback_provider.dart index 7c1f0193a..e78bc06e2 100644 --- a/lib/provider/playback_provider.dart +++ b/lib/provider/playback_provider.dart @@ -13,6 +13,7 @@ import 'package:spotube/models/current_playlist.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/audio_player_provider.dart'; +import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/youtube_provider.dart'; import 'package:spotube/services/linux_audio_service.dart'; @@ -56,6 +57,9 @@ class Playback extends PersistedChangeNotifier { YoutubeExplode youtube; Ref ref; UserPreferences get preferences => ref.read(userPreferencesProvider); + Set get blacklist => ref.read(BlackListNotifier.provider); + BlackListNotifier get blacklistNotifier => + ref.read(BlackListNotifier.provider.notifier); // playlist & track list properties late LazyBox cache; @@ -197,7 +201,7 @@ class Playback extends PersistedChangeNotifier { try { if (index < 0 || index > playlist.tracks.length - 1) return; if (isPlaying || status == PlaybackStatus.playing) await stop(); - this.playlist = playlist; + this.playlist = blacklistNotifier.filterPlaylist(playlist); mobileAudioService?.session?.setActive(true); final played = this.playlist!.tracks[index]; status = PlaybackStatus.loading; diff --git a/lib/utils/persisted_state_notifier.dart b/lib/utils/persisted_state_notifier.dart new file mode 100644 index 000000000..ea569098a --- /dev/null +++ b/lib/utils/persisted_state_notifier.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +abstract class PersistedStateNotifier extends StateNotifier { + get cacheKey => state.runtimeType.toString(); + + SharedPreferences? localStorage; + + PersistedStateNotifier(super.state) : super() { + SharedPreferences.getInstance().then( + (localStorage) { + this.localStorage = localStorage; + final rawState = localStorage.getString(cacheKey); + + if (rawState != null) { + state = fromJson(jsonDecode(rawState)); + } + }, + ); + } + + T fromJson(Map json); + Map toJson(); + + @override + set state(T value) { + if (state == value) return; + super.state = value; + if (localStorage == null) { + SharedPreferences.getInstance().then( + (localStorage) { + this.localStorage = localStorage; + localStorage.setString( + cacheKey, + jsonEncode(toJson()), + ); + }, + ); + } else { + localStorage?.setString( + cacheKey, + jsonEncode(toJson()), + ); + } + } +}