feat(user-library): search for user playlists

This commit is contained in:
Kingkor Roy Tirtho 2023-01-05 20:05:27 +06:00
parent e158dd0cec
commit af4d56fd41
11 changed files with 386 additions and 256 deletions

View File

@ -11,7 +11,12 @@ import 'package:spotube/utils/type_conversion_utils.dart';
class AlbumCard extends HookConsumerWidget { class AlbumCard extends HookConsumerWidget {
final Album album; 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 @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -25,6 +30,7 @@ class AlbumCard extends HookConsumerWidget {
album.images, album.images,
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
), ),
viewType: viewType,
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
isPlaying: isPlaylistPlaying && playback.isPlaying, isPlaying: isPlaylistPlaying && playback.isPlaying,
isLoading: playback.status == PlaybackStatus.loading && isLoading: playback.status == PlaybackStatus.loading &&

View File

@ -23,6 +23,7 @@ import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge.dart' show FfiException;
const supportedAudioTypes = [ const supportedAudioTypes = [
"audio/webm", "audio/webm",
@ -88,8 +89,15 @@ final localTracksProvider = FutureProvider<List<Track>>((ref) async {
} }
return {"metadata": metadata, "file": f, "art": imageFile.path}; return {"metadata": metadata, "file": f, "art": imageFile.path};
} catch (e, stack) { } on FfiException catch (e) {
getLogger(FutureProvider).e("[Fetching metadata]", e, stack); if (e.message == "NoTag: reader does not contain an id3 tag") {
getLogger(FutureProvider<List<Track>>)
.w("[Fetching metadata]", e.message);
}
return {};
} on Exception catch (e, stack) {
getLogger(FutureProvider<List<Track>>)
.e("[Fetching metadata]", e, stack);
return {}; return {};
} }
}, },

View File

@ -1,30 +1,44 @@
import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart' hide Image; 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:platform_ui/platform_ui.dart'; import 'package:platform_ui/platform_ui.dart';
import 'package:spotify/spotify.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/shimmers/shimmer_playbutton_card.dart';
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart'; import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/playlist/playlist_card.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/auth_provider.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
import 'package:tuple/tuple.dart';
class UserPlaylists extends HookConsumerWidget { class UserPlaylists extends HookConsumerWidget {
const UserPlaylists({Key? key}) : super(key: key); const UserPlaylists({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final searchText = useState('');
final breakpoint = useBreakpoints();
final spacing = useBreakpointValue<double>(
sm: 0,
others: 20,
);
final viewType = MediaQuery.of(context).size.width < 480
? PlaybuttonCardViewType.list
: PlaybuttonCardViewType.square;
final auth = ref.watch(authProvider); final auth = ref.watch(authProvider);
if (auth.isAnonymous) {
return const AnonymousFallback();
}
final playlistsQuery = useQuery( final playlistsQuery = useQuery(
job: Queries.playlist.ofMine, job: Queries.playlist.ofMine,
externalData: ref.watch(spotifyProvider), externalData: ref.watch(spotifyProvider),
); );
Image image = Image(); Image image = Image();
image.height = 300; image.height = 300;
image.width = 300; image.width = 300;
@ -37,27 +51,64 @@ class UserPlaylists extends HookConsumerWidget {
image.url = "https://t.scdn.co/images/3099b3803ad9496896c43f22fe9be8c4.png"; image.url = "https://t.scdn.co/images/3099b3803ad9496896c43f22fe9be8c4.png";
likedTracksPlaylist.images = [image]; 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) { if (playlistsQuery.isLoading || !playlistsQuery.hasData) {
return const Center(child: ShimmerPlaybuttonCard(count: 7)); return const Center(child: ShimmerPlaybuttonCard(count: 7));
} }
final children = [
const PlaylistCreateDialog(),
...playlists
.map((playlist) => PlaylistCard(
playlist,
viewType: viewType,
))
.toList(),
];
return SingleChildScrollView( return SingleChildScrollView(
child: Material( child: Material(
type: MaterialType.transparency, type: MaterialType.transparency,
textStyle: PlatformTheme.of(context).textTheme!.body!, textStyle: PlatformTheme.of(context).textTheme!.body!,
child: Container( child: Padding(
width: double.infinity,
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Wrap( child: Column(
spacing: 20, // gap between adjacent chips
runSpacing: 20, // gap between lines
alignment: WrapAlignment.center,
children: [ children: [
const PlaylistCreateDialog(), PlatformTextField(
PlaylistCard(likedTracksPlaylist), onChanged: (value) => searchText.value = value,
...playlistsQuery.data! placeholder: "Search your playlists...",
.map((playlist) => PlaylistCard(playlist)) prefixIcon: Icons.search,
.toList(), ),
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,
),
),
], ],
), ),
), ),

View File

@ -11,7 +11,12 @@ import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistCard extends HookConsumerWidget { class PlaylistCard extends HookConsumerWidget {
final PlaylistSimple playlist; 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 @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider); Playback playback = ref.watch(playbackProvider);
@ -21,6 +26,7 @@ class PlaylistCard extends HookConsumerWidget {
final int marginH = final int marginH =
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
return PlaybuttonCard( return PlaybuttonCard(
viewType: viewType,
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()), margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
title: playlist.name!, title: playlist.name!,
imageUrl: TypeConversionUtils.image_X_UrlString( imageUrl: TypeConversionUtils.image_X_UrlString(

View File

@ -15,7 +15,9 @@ class PlaylistCreateDialog extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
return PlatformTextButton( return SizedBox(
width: 200,
child: PlatformTextButton(
onPressed: () { onPressed: () {
showPlatformAlertDialog( showPlatformAlertDialog(
context, context,
@ -46,7 +48,7 @@ class PlaylistCreateDialog extends HookConsumerWidget {
return PlatformAlertDialog( return PlatformAlertDialog(
macosAppIcon: Sidebar.brandLogo(), macosAppIcon: Sidebar.brandLogo(),
title: const Text("Create a Playlist"), title: const PlatformText("Create a Playlist"),
primaryActions: [ primaryActions: [
PlatformBuilder( PlatformBuilder(
fallback: PlatformBuilderFallback.android, fallback: PlatformBuilderFallback.android,
@ -116,7 +118,8 @@ class PlaylistCreateDialog extends HookConsumerWidget {
PlatformCheckbox( PlatformCheckbox(
value: collaborative.value, value: collaborative.value,
label: const PlatformText("Collaborative"), label: const PlatformText("Collaborative"),
onChanged: (val) => collaborative.value = val ?? false, onChanged: (val) =>
collaborative.value = val ?? false,
), ),
], ],
), ),
@ -128,16 +131,18 @@ class PlaylistCreateDialog extends HookConsumerWidget {
}, },
style: ButtonStyle( style: ButtonStyle(
padding: MaterialStateProperty.all( padding: MaterialStateProperty.all(
const EdgeInsets.symmetric(horizontal: 15, vertical: 100)), const EdgeInsets.symmetric(vertical: 100),
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: const [ children: const [
Icon(Icons.add_box_rounded, size: 50), Icon(Icons.add_box_rounded, size: 40),
Text("Create Playlist", style: TextStyle(fontSize: 22)), PlatformText("Create Playlist", style: TextStyle(fontSize: 20)),
], ],
), ),
),
); );
} }
} }

View File

@ -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/components/shared/image/universal_image.dart';
import 'package:spotube/hooks/use_platform_property.dart'; import 'package:spotube/hooks/use_platform_property.dart';
enum PlaybuttonCardViewType { square, list }
class PlaybuttonCard extends HookWidget { class PlaybuttonCard extends HookWidget {
final void Function()? onTap; final void Function()? onTap;
final void Function()? onPlaybuttonPressed; final void Function()? onPlaybuttonPressed;
@ -15,6 +17,8 @@ class PlaybuttonCard extends HookWidget {
final bool isPlaying; final bool isPlaying;
final bool isLoading; final bool isLoading;
final String title; final String title;
final PlaybuttonCardViewType viewType;
const PlaybuttonCard({ const PlaybuttonCard({
required this.imageUrl, required this.imageUrl,
required this.isPlaying, required this.isPlaying,
@ -24,6 +28,7 @@ class PlaybuttonCard extends HookWidget {
this.description, this.description,
this.onPlaybuttonPressed, this.onPlaybuttonPressed,
this.onTap, this.onTap,
this.viewType = PlaybuttonCardViewType.square,
Key? key, Key? key,
}) : super(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( return Container(
margin: margin, margin: margin,
@ -66,8 +71,132 @@ class PlaybuttonCard extends HookWidget {
splashFactory: splash, splashFactory: splash,
highlightColor: Colors.black12, highlightColor: Colors.black12,
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200), constraints: BoxConstraints(
maxWidth: isSquare ? 200 : double.infinity,
maxHeight: !isSquare ? 60 : double.infinity,
),
child: HoverBuilder(builder: (context, isHovering) { 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( return Ink(
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor, color: backgroundColor,
@ -89,103 +218,7 @@ class PlaybuttonCard extends HookWidget {
) )
: null, : null,
), ),
child: Column( child: isSquare ? square : list,
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,
),
),
]
],
),
),
],
),
); );
}), }),
), ),

View File

@ -30,7 +30,8 @@ class Waypoint extends HookWidget {
// nextPageTrigger will have a value equivalent to 80% of the list size. // nextPageTrigger will have a value equivalent to 80% of the list size.
final nextPageTrigger = 0.8 * controller.position.maxScrollExtent; 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()) { if (controller.position.pixels >= nextPageTrigger && isMounted()) {
await onTouchEdge?.call(); await onTouchEdge?.call();
} }
@ -39,9 +40,8 @@ class Waypoint extends HookWidget {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (controller.hasClients && isMounted()) { if (controller.hasClients && isMounted()) {
listener(); listener();
}
controller.addListener(listener); controller.addListener(listener);
}
}); });
return () => controller.removeListener(listener); return () => controller.removeListener(listener);
}, [controller, onTouchEdge, isMounted]); }, [controller, onTouchEdge, isMounted]);

View File

@ -57,7 +57,12 @@ class _AutoScrollControllerHook extends Hook<AutoScrollController> {
class _AutoScrollControllerHookState class _AutoScrollControllerHookState
extends HookState<AutoScrollController, _AutoScrollControllerHook> { extends HookState<AutoScrollController, _AutoScrollControllerHook> {
late final controller = AutoScrollController( late final AutoScrollController controller;
@override
void initHook() {
super.initHook();
controller = AutoScrollController(
initialScrollOffset: hook.initialScrollOffset, initialScrollOffset: hook.initialScrollOffset,
keepScrollOffset: hook.keepScrollOffset, keepScrollOffset: hook.keepScrollOffset,
debugLabel: hook.debugLabel, debugLabel: hook.debugLabel,
@ -66,6 +71,7 @@ class _AutoScrollControllerHookState
suggestedRowHeight: hook.suggestedRowHeight, suggestedRowHeight: hook.suggestedRowHeight,
viewportBoundaryGetter: hook.viewportBoundaryGetter, viewportBoundaryGetter: hook.viewportBoundaryGetter,
); );
}
@override @override
AutoScrollController build(BuildContext context) => controller; AutoScrollController build(BuildContext context) => controller;

View File

@ -1,17 +1,24 @@
import 'package:spotube/hooks/use_breakpoints.dart'; import 'package:spotube/hooks/use_breakpoints.dart';
useBreakpointValue({sm, md, lg, xl, xxl}) { useBreakpointValue<T>({
T? sm,
T? md,
T? lg,
T? xl,
T? xxl,
T? others,
}) {
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
if (breakpoint.isSm) { if (breakpoint.isSm) {
return sm; return sm ?? others;
} else if (breakpoint.isMd) { } else if (breakpoint.isMd) {
return md; return md ?? others;
} else if (breakpoint.isXl) { } else if (breakpoint.isXl) {
return xl; return xl ?? others;
} else if (breakpoint.isXxl) { } else if (breakpoint.isXxl) {
return xxl; return xxl ?? others;
} else { } else {
return lg; return lg ?? others;
} }
} }

View File

@ -646,6 +646,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.1.0" version: "3.1.0"
fuzzywuzzy:
dependency: "direct main"
description:
name: fuzzywuzzy
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.0"
glob: glob:
dependency: transitive dependency: transitive
description: description:

View File

@ -68,6 +68,7 @@ dependencies:
libadwaita: ^1.2.5 libadwaita: ^1.2.5
adwaita: ^0.5.2 adwaita: ^0.5.2
flutter_svg: ^1.1.6 flutter_svg: ^1.1.6
fuzzywuzzy: ^0.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: