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 {
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 &&

View File

@ -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<List<Track>>((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<List<Track>>)
.w("[Fetching metadata]", e.message);
}
return {};
} on Exception catch (e, stack) {
getLogger(FutureProvider<List<Track>>)
.e("[Fetching metadata]", e, stack);
return {};
}
},

View File

@ -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<double>(
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,
),
),
],
),
),

View File

@ -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(

View File

@ -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,
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 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,
),
],
),
),
);
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,
),
],
),
),
);
});
},
);
},
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)),
],
),
),
);
}

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/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,
);
}),
),

View File

@ -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]);

View File

@ -57,15 +57,21 @@ class _AutoScrollControllerHook extends Hook<AutoScrollController> {
class _AutoScrollControllerHookState
extends HookState<AutoScrollController, _AutoScrollControllerHook> {
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;

View File

@ -1,17 +1,24 @@
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();
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;
}
}

View File

@ -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:

View File

@ -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: