diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index c661e2dac..5c9f55379 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -11,7 +11,12 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class AlbumCard extends HookConsumerWidget { final Album album; - const AlbumCard(this.album, {Key? key}) : super(key: key); + final PlaybuttonCardViewType viewType; + const AlbumCard( + this.album, { + Key? key, + this.viewType = PlaybuttonCardViewType.square, + }) : super(key: key); @override Widget build(BuildContext context, ref) { @@ -25,6 +30,7 @@ class AlbumCard extends HookConsumerWidget { album.images, placeholder: ImagePlaceholder.collection, ), + viewType: viewType, margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), isPlaying: isPlaylistPlaying && playback.isPlaying, isLoading: playback.status == PlaybackStatus.loading && diff --git a/lib/components/library/user_local_tracks.dart b/lib/components/library/user_local_tracks.dart index 698854b12..53a409ef7 100644 --- a/lib/components/library/user_local_tracks.dart +++ b/lib/components/library/user_local_tracks.dart @@ -23,6 +23,7 @@ import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/primitive_utils.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 = [ "audio/webm", @@ -88,8 +89,15 @@ final localTracksProvider = FutureProvider>((ref) async { } return {"metadata": metadata, "file": f, "art": imageFile.path}; - } catch (e, stack) { - getLogger(FutureProvider).e("[Fetching metadata]", e, stack); + } on FfiException catch (e) { + if (e.message == "NoTag: reader does not contain an id3 tag") { + getLogger(FutureProvider>) + .w("[Fetching metadata]", e.message); + } + return {}; + } on Exception catch (e, stack) { + getLogger(FutureProvider>) + .e("[Fetching metadata]", e, stack); return {}; } }, diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index de53d502c..4472dec4c 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -1,30 +1,44 @@ import 'package:fl_query_hooks/fl_query_hooks.dart'; 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:hooks_riverpod/hooks_riverpod.dart'; import 'package:platform_ui/platform_ui.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/playlist/playlist_create_dialog.dart'; +import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/playlist/playlist_card.dart'; -import 'package:spotube/components/playlist/playlist_create_dialog.dart'; +import 'package:spotube/hooks/use_breakpoint_value.dart'; +import 'package:spotube/hooks/use_breakpoints.dart'; import 'package:spotube/provider/auth_provider.dart'; import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/services/queries/queries.dart'; +import 'package:tuple/tuple.dart'; class UserPlaylists extends HookConsumerWidget { const UserPlaylists({Key? key}) : super(key: key); @override Widget build(BuildContext context, ref) { + final searchText = useState(''); + final breakpoint = useBreakpoints(); + final spacing = useBreakpointValue( + sm: 0, + others: 20, + ); + final viewType = MediaQuery.of(context).size.width < 480 + ? PlaybuttonCardViewType.list + : PlaybuttonCardViewType.square; final auth = ref.watch(authProvider); - if (auth.isAnonymous) { - return const AnonymousFallback(); - } final playlistsQuery = useQuery( job: Queries.playlist.ofMine, externalData: ref.watch(spotifyProvider), ); + Image image = Image(); image.height = 300; image.width = 300; @@ -37,27 +51,64 @@ class UserPlaylists extends HookConsumerWidget { image.url = "https://t.scdn.co/images/3099b3803ad9496896c43f22fe9be8c4.png"; likedTracksPlaylist.images = [image]; + final playlists = useMemoized( + () => [ + likedTracksPlaylist, + ...?playlistsQuery.data, + ] + .map((e) => Tuple2( + searchText.value.isEmpty + ? 100 + : weightedRatio(e.name!, searchText.value), + e, + )) + .sorted((a, b) => b.item1.compareTo(a.item1)) + .where((e) => e.item1 > 50) + .map((e) => e.item2) + .toList(), + [playlistsQuery.data, searchText.value], + ); + + if (auth.isAnonymous) { + return const AnonymousFallback(); + } if (playlistsQuery.isLoading || !playlistsQuery.hasData) { return const Center(child: ShimmerPlaybuttonCard(count: 7)); } + final children = [ + const PlaylistCreateDialog(), + ...playlists + .map((playlist) => PlaylistCard( + playlist, + viewType: viewType, + )) + .toList(), + ]; return SingleChildScrollView( child: Material( type: MaterialType.transparency, textStyle: PlatformTheme.of(context).textTheme!.body!, - child: Container( - width: double.infinity, + child: Padding( padding: const EdgeInsets.all(8.0), - child: Wrap( - spacing: 20, // gap between adjacent chips - runSpacing: 20, // gap between lines - alignment: WrapAlignment.center, + child: Column( children: [ - const PlaylistCreateDialog(), - PlaylistCard(likedTracksPlaylist), - ...playlistsQuery.data! - .map((playlist) => PlaylistCard(playlist)) - .toList(), + PlatformTextField( + onChanged: (value) => searchText.value = value, + placeholder: "Search your playlists...", + prefixIcon: Icons.search, + ), + const SizedBox(height: 20), + Center( + child: Wrap( + spacing: spacing, // gap between adjacent chips + runSpacing: 20, // gap between lines + alignment: breakpoint.isSm + ? WrapAlignment.center + : WrapAlignment.start, + children: children, + ), + ), ], ), ), diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index a92577be1..a625f0596 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -11,7 +11,12 @@ import 'package:spotube/utils/type_conversion_utils.dart'; class PlaylistCard extends HookConsumerWidget { final PlaylistSimple playlist; - const PlaylistCard(this.playlist, {Key? key}) : super(key: key); + final PlaybuttonCardViewType viewType; + const PlaylistCard( + this.playlist, { + Key? key, + this.viewType = PlaybuttonCardViewType.square, + }) : super(key: key); @override Widget build(BuildContext context, ref) { Playback playback = ref.watch(playbackProvider); @@ -21,6 +26,7 @@ class PlaylistCard extends HookConsumerWidget { final int marginH = useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); return PlaybuttonCard( + viewType: viewType, margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), title: playlist.name!, imageUrl: TypeConversionUtils.image_X_UrlString( diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index c43e9b832..3a54309a0 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -15,128 +15,133 @@ class PlaylistCreateDialog extends HookConsumerWidget { Widget build(BuildContext context, ref) { final spotify = ref.watch(spotifyProvider); - return PlatformTextButton( - onPressed: () { - showPlatformAlertDialog( - context, - builder: (context) { - return HookBuilder(builder: (context) { - final playlistName = useTextEditingController(); - final description = useTextEditingController(); - final public = useState(false); - final collaborative = useState(false); + return SizedBox( + width: 200, + child: PlatformTextButton( + onPressed: () { + showPlatformAlertDialog( + context, + builder: (context) { + return HookBuilder(builder: (context) { + final playlistName = useTextEditingController(); + final description = useTextEditingController(); + final public = useState(false); + final collaborative = useState(false); - onCreate() async { - if (playlistName.text.isEmpty) return; - final me = await spotify.me.get(); - await spotify.playlists.createPlaylist( - me.id!, - playlistName.text, - collaborative: collaborative.value, - public: public.value, - description: description.text, - ); - await QueryBowl.of(context) - .getQuery( - Queries.playlist.ofMine.queryKey, - ) - ?.refetch(); - Navigator.pop(context); - } + onCreate() async { + if (playlistName.text.isEmpty) return; + final me = await spotify.me.get(); + await spotify.playlists.createPlaylist( + me.id!, + playlistName.text, + collaborative: collaborative.value, + public: public.value, + description: description.text, + ); + await QueryBowl.of(context) + .getQuery( + Queries.playlist.ofMine.queryKey, + ) + ?.refetch(); + Navigator.pop(context); + } - return PlatformAlertDialog( - macosAppIcon: Sidebar.brandLogo(), - title: const Text("Create a Playlist"), - primaryActions: [ - PlatformBuilder( - fallback: PlatformBuilderFallback.android, - android: (context, _) { - return PlatformFilledButton( - onPressed: onCreate, - child: const Text("Create"), - ); - }, - ios: (context, data) { - return CupertinoDialogAction( - isDefaultAction: true, - onPressed: onCreate, - child: const Text("Create"), - ); - }, - ), - ], - secondaryActions: [ - PlatformBuilder( - fallback: PlatformBuilderFallback.android, - android: (context, _) { - return PlatformFilledButton( - isSecondary: true, - child: const Text("Cancel"), - onPressed: () { - Navigator.pop(context); - }, - ); - }, - ios: (context, data) { - return CupertinoDialogAction( - onPressed: () { - Navigator.pop(context); - }, - isDestructiveAction: true, - child: const Text("Cancel"), - ); - }, - ), - ], - content: Container( - width: MediaQuery.of(context).size.width, - constraints: const BoxConstraints(maxWidth: 500), - child: ListView( - shrinkWrap: true, - children: [ - PlatformTextField( - controller: playlistName, - placeholder: "Name of the playlist", - label: "Playlist Name", - ), - const SizedBox(height: 10), - PlatformTextField( - controller: description, - placeholder: "Description...", - keyboardType: TextInputType.multiline, - maxLines: 5, - ), - const SizedBox(height: 10), - PlatformCheckbox( - value: public.value, - label: const PlatformText("Public"), - onChanged: (val) => public.value = val ?? false, - ), - const SizedBox(height: 10), - PlatformCheckbox( - value: collaborative.value, - label: const PlatformText("Collaborative"), - onChanged: (val) => collaborative.value = val ?? false, - ), - ], + return PlatformAlertDialog( + macosAppIcon: Sidebar.brandLogo(), + title: const PlatformText("Create a Playlist"), + primaryActions: [ + PlatformBuilder( + fallback: PlatformBuilderFallback.android, + android: (context, _) { + return PlatformFilledButton( + onPressed: onCreate, + child: const Text("Create"), + ); + }, + ios: (context, data) { + return CupertinoDialogAction( + isDefaultAction: true, + onPressed: onCreate, + child: const Text("Create"), + ); + }, + ), + ], + secondaryActions: [ + PlatformBuilder( + fallback: PlatformBuilderFallback.android, + android: (context, _) { + return PlatformFilledButton( + isSecondary: true, + child: const Text("Cancel"), + onPressed: () { + Navigator.pop(context); + }, + ); + }, + ios: (context, data) { + return CupertinoDialogAction( + onPressed: () { + Navigator.pop(context); + }, + isDestructiveAction: true, + child: const Text("Cancel"), + ); + }, + ), + ], + content: Container( + width: MediaQuery.of(context).size.width, + constraints: const BoxConstraints(maxWidth: 500), + child: ListView( + shrinkWrap: true, + children: [ + PlatformTextField( + controller: playlistName, + placeholder: "Name of the playlist", + label: "Playlist Name", + ), + const SizedBox(height: 10), + PlatformTextField( + controller: description, + placeholder: "Description...", + keyboardType: TextInputType.multiline, + maxLines: 5, + ), + const SizedBox(height: 10), + PlatformCheckbox( + value: public.value, + label: const PlatformText("Public"), + onChanged: (val) => public.value = val ?? false, + ), + const SizedBox(height: 10), + PlatformCheckbox( + value: collaborative.value, + label: const PlatformText("Collaborative"), + onChanged: (val) => + collaborative.value = val ?? false, + ), + ], + ), ), - ), - ); - }); - }, - ); - }, - style: ButtonStyle( - padding: MaterialStateProperty.all( - const EdgeInsets.symmetric(horizontal: 15, vertical: 100)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - Icon(Icons.add_box_rounded, size: 50), - Text("Create Playlist", style: TextStyle(fontSize: 22)), - ], + ); + }); + }, + ); + }, + style: ButtonStyle( + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(vertical: 100), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Icon(Icons.add_box_rounded, size: 40), + PlatformText("Create Playlist", style: TextStyle(fontSize: 20)), + ], + ), ), ); } diff --git a/lib/components/shared/playbutton_card.dart b/lib/components/shared/playbutton_card.dart index ce4f99b14..2469af128 100644 --- a/lib/components/shared/playbutton_card.dart +++ b/lib/components/shared/playbutton_card.dart @@ -6,6 +6,8 @@ import 'package:spotube/components/shared/spotube_marquee_text.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/hooks/use_platform_property.dart'; +enum PlaybuttonCardViewType { square, list } + class PlaybuttonCard extends HookWidget { final void Function()? onTap; final void Function()? onPlaybuttonPressed; @@ -15,6 +17,8 @@ class PlaybuttonCard extends HookWidget { final bool isPlaying; final bool isLoading; final String title; + final PlaybuttonCardViewType viewType; + const PlaybuttonCard({ required this.imageUrl, required this.isPlaying, @@ -24,6 +28,7 @@ class PlaybuttonCard extends HookWidget { this.description, this.onPlaybuttonPressed, this.onTap, + this.viewType = PlaybuttonCardViewType.square, Key? key, }) : super(key: key); @@ -56,7 +61,7 @@ class PlaybuttonCard extends HookWidget { ), ); - final iconBgColor = PlatformTheme.of(context).primaryColor; + final isSquare = viewType == PlaybuttonCardViewType.square; return Container( margin: margin, @@ -66,8 +71,132 @@ class PlaybuttonCard extends HookWidget { splashFactory: splash, highlightColor: Colors.black12, child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 200), + constraints: BoxConstraints( + maxWidth: isSquare ? 200 : double.infinity, + maxHeight: !isSquare ? 60 : double.infinity, + ), child: HoverBuilder(builder: (context, isHovering) { + final playButton = PlatformIconButton( + onPressed: onPlaybuttonPressed, + backgroundColor: PlatformTheme.of(context).primaryColor, + hoverColor: + PlatformTheme.of(context).primaryColor?.withOpacity(0.5), + icon: isLoading + ? SizedBox( + height: 23, + width: 23, + child: PlatformCircularProgressIndicator( + color: ThemeData.estimateBrightnessForColor( + PlatformTheme.of(context).primaryColor!, + ) == + Brightness.dark + ? Colors.white + : Colors.grey[900], + ), + ) + : Icon( + isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + color: Colors.white, + ), + ); + final image = Padding( + padding: EdgeInsets.all( + platform == TargetPlatform.windows ? 5 : 0, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular( + [TargetPlatform.windows, TargetPlatform.linux] + .contains(platform) + ? 5 + : 8, + ), + child: UniversalImage( + path: imageUrl, + width: isSquare ? 200 : 60, + placeholder: (context, url) => + Image.asset("assets/placeholder.png"), + ), + ), + ); + + final square = Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // thumbnail of the playlist + Stack( + children: [ + image, + Positioned.directional( + textDirection: TextDirection.ltr, + bottom: 10, + end: 5, + child: playButton, + ) + ], + ), + const SizedBox(height: 5), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Column( + children: [ + Tooltip( + message: title, + child: SizedBox( + height: 20, + child: SpotubeMarqueeText( + text: title, + style: const TextStyle(fontWeight: FontWeight.bold), + isHovering: isHovering, + ), + ), + ), + if (description != null) ...[ + const SizedBox(height: 10), + SizedBox( + height: 30, + child: SpotubeMarqueeText( + text: description!, + style: PlatformTextTheme.of(context).caption, + isHovering: isHovering, + ), + ), + ] + ], + ), + ), + ], + ); + + final list = Row( + children: [ + // thumbnail of the playlist + image, + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + PlatformText( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 5), + if (description != null) + PlatformText( + description!, + overflow: TextOverflow.fade, + style: PlatformTextTheme.of(context).caption, + ), + ], + ), + const Spacer(), + playButton, + ], + ); + return Ink( decoration: BoxDecoration( color: backgroundColor, @@ -89,103 +218,7 @@ class PlaybuttonCard extends HookWidget { ) : null, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // thumbnail of the playlist - Stack( - children: [ - Padding( - padding: EdgeInsets.all( - platform == TargetPlatform.windows ? 5 : 0, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular( - [TargetPlatform.windows, TargetPlatform.linux] - .contains(platform) - ? 5 - : 8, - ), - child: UniversalImage( - path: imageUrl, - width: 200, - placeholder: (context, url) => - Image.asset("assets/placeholder.png"), - ), - ), - ), - Positioned.directional( - textDirection: TextDirection.ltr, - bottom: 10, - end: 5, - child: Builder(builder: (context) { - return PlatformIconButton( - onPressed: onPlaybuttonPressed, - backgroundColor: - PlatformTheme.of(context).primaryColor, - hoverColor: PlatformTheme.of(context) - .primaryColor - ?.withOpacity(0.5), - icon: isLoading - ? SizedBox( - height: 23, - width: 23, - child: PlatformCircularProgressIndicator( - color: - ThemeData.estimateBrightnessForColor( - PlatformTheme.of(context) - .primaryColor!, - ) == - Brightness.dark - ? Colors.white - : Colors.grey[900], - ), - ) - : Icon( - isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, - color: Colors.white, - ), - ); - }), - ) - ], - ), - const SizedBox(height: 5), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 10), - child: Column( - children: [ - Tooltip( - message: title, - child: SizedBox( - height: 20, - child: SpotubeMarqueeText( - text: title, - style: - const TextStyle(fontWeight: FontWeight.bold), - isHovering: isHovering, - ), - ), - ), - if (description != null) ...[ - const SizedBox(height: 10), - SizedBox( - height: 30, - child: SpotubeMarqueeText( - text: description!, - style: PlatformTextTheme.of(context).caption, - isHovering: isHovering, - ), - ), - ] - ], - ), - ), - ], - ), + child: isSquare ? square : list, ); }), ), diff --git a/lib/components/shared/waypoint.dart b/lib/components/shared/waypoint.dart index 78c7a2685..abd9f98da 100644 --- a/lib/components/shared/waypoint.dart +++ b/lib/components/shared/waypoint.dart @@ -30,7 +30,8 @@ class Waypoint extends HookWidget { // nextPageTrigger will have a value equivalent to 80% of the list size. final nextPageTrigger = 0.8 * controller.position.maxScrollExtent; -// scrollController fetches the next paginated data when the current postion of the user on the screen has surpassed + // scrollController fetches the next paginated data when the current + // position of the user on the screen has surpassed if (controller.position.pixels >= nextPageTrigger && isMounted()) { await onTouchEdge?.call(); } @@ -39,9 +40,8 @@ class Waypoint extends HookWidget { WidgetsBinding.instance.addPostFrameCallback((_) { if (controller.hasClients && isMounted()) { listener(); + controller.addListener(listener); } - - controller.addListener(listener); }); return () => controller.removeListener(listener); }, [controller, onTouchEdge, isMounted]); diff --git a/lib/hooks/use_auto_scroll_controller.dart b/lib/hooks/use_auto_scroll_controller.dart index d62670821..8edfb0411 100644 --- a/lib/hooks/use_auto_scroll_controller.dart +++ b/lib/hooks/use_auto_scroll_controller.dart @@ -57,15 +57,21 @@ class _AutoScrollControllerHook extends Hook { class _AutoScrollControllerHookState extends HookState { - late final controller = AutoScrollController( - initialScrollOffset: hook.initialScrollOffset, - keepScrollOffset: hook.keepScrollOffset, - debugLabel: hook.debugLabel, - axis: hook.axis, - copyTagsFrom: hook.copyTagsFrom, - suggestedRowHeight: hook.suggestedRowHeight, - viewportBoundaryGetter: hook.viewportBoundaryGetter, - ); + late final AutoScrollController controller; + + @override + void initHook() { + super.initHook(); + controller = AutoScrollController( + initialScrollOffset: hook.initialScrollOffset, + keepScrollOffset: hook.keepScrollOffset, + debugLabel: hook.debugLabel, + axis: hook.axis, + copyTagsFrom: hook.copyTagsFrom, + suggestedRowHeight: hook.suggestedRowHeight, + viewportBoundaryGetter: hook.viewportBoundaryGetter, + ); + } @override AutoScrollController build(BuildContext context) => controller; diff --git a/lib/hooks/use_breakpoint_value.dart b/lib/hooks/use_breakpoint_value.dart index c00ba9b9a..d50482ac6 100644 --- a/lib/hooks/use_breakpoint_value.dart +++ b/lib/hooks/use_breakpoint_value.dart @@ -1,17 +1,24 @@ import 'package:spotube/hooks/use_breakpoints.dart'; -useBreakpointValue({sm, md, lg, xl, xxl}) { +useBreakpointValue({ + T? sm, + T? md, + T? lg, + T? xl, + T? xxl, + T? others, +}) { final breakpoint = useBreakpoints(); if (breakpoint.isSm) { - return sm; + return sm ?? others; } else if (breakpoint.isMd) { - return md; + return md ?? others; } else if (breakpoint.isXl) { - return xl; + return xl ?? others; } else if (breakpoint.isXxl) { - return xxl; + return xxl ?? others; } else { - return lg; + return lg ?? others; } } diff --git a/pubspec.lock b/pubspec.lock index 181de6af2..bd28171a3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -646,6 +646,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "3.1.0" + fuzzywuzzy: + dependency: "direct main" + description: + name: fuzzywuzzy + url: "https://pub.dartlang.org" + source: hosted + version: "0.2.0" glob: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a8bd8b532..b252f0cf7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: libadwaita: ^1.2.5 adwaita: ^0.5.2 flutter_svg: ^1.1.6 + fuzzywuzzy: ^0.2.0 dev_dependencies: flutter_test: