From f4b0d134ca724b75bf65b885bce4dba206f1e090 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 6 Jun 2023 17:41:37 +0600 Subject: [PATCH] feat: custom playlist generator --- lib/collections/routes.dart | 29 +- lib/collections/spotube_icons.dart | 2 + .../playlist_generate/multi_select_field.dart | 271 ++++++++++++++++ .../seeds_multi_autocomplete.dart | 124 ++++++++ .../playlist_generate/simple_track_tile.dart | 46 +++ lib/components/library/user_playlists.dart | 25 +- .../playlist/playlist_create_dialog.dart | 194 ++++++------ lib/extensions/constrains.dart | 25 ++ lib/l10n/app_en.arb | 3 +- lib/main.dart | 1 - .../playlist_generate/playlist_generate.dart | 296 ++++++++++++++++++ .../playlist_generate_result.dart | 202 ++++++++++++ .../spotify_endpoints.dart | 80 +++++ lib/services/queries/category.dart | 23 ++ lib/services/queries/playlist.dart | 87 +++++ lib/utils/custom_toast_handler.dart | 2 +- 16 files changed, 1301 insertions(+), 109 deletions(-) create mode 100644 lib/components/library/playlist_generate/multi_select_field.dart create mode 100644 lib/components/library/playlist_generate/seeds_multi_autocomplete.dart create mode 100644 lib/components/library/playlist_generate/simple_track_tile.dart create mode 100644 lib/extensions/constrains.dart create mode 100644 lib/pages/library/playlist_generate/playlist_generate.dart create mode 100644 lib/pages/library/playlist_generate/playlist_generate_result.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index ff2700446..fc0fb8381 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:spotify/spotify.dart' hide Search; import 'package:spotube/pages/home/home.dart'; +import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart'; import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/settings/blacklist.dart'; @@ -21,6 +22,8 @@ import 'package:spotube/pages/root/root_app.dart'; import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart'; +import '../pages/library/playlist_generate/playlist_generate_result.dart'; + final rootNavigatorKey = Catcher.navigatorKey; final shellRouteNavigatorKey = GlobalKey(); final router = GoRouter( @@ -41,11 +44,27 @@ final router = GoRouter( const SpotubePage(child: SearchPage()), ), GoRoute( - path: "/library", - name: "Library", - pageBuilder: (context, state) => - const SpotubePage(child: LibraryPage()), - ), + path: "/library", + name: "Library", + pageBuilder: (context, state) => + const SpotubePage(child: LibraryPage()), + routes: [ + GoRoute( + path: "generate", + pageBuilder: (context, state) => + const SpotubePage(child: PlaylistGeneratorPage()), + routes: [ + GoRoute( + path: "result", + pageBuilder: (context, state) => SpotubePage( + child: PlaylistGenerateResultPage( + state: + state.extra as PlaylistGenerateResultRouteState, + ), + ), + ), + ]), + ]), GoRoute( path: "/lyrics", name: "Lyrics", diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index d435a89f4..1fce30ec8 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -80,4 +80,6 @@ abstract class SpotubeIcons { static const language = FeatherIcons.globe; static const error = FeatherIcons.alertTriangle; static const piped = FeatherIcons.cloud; + static const magic = Icons.auto_fix_high_outlined; + static const selectionCheck = Icons.checklist_rounded; } diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/components/library/playlist_generate/multi_select_field.dart new file mode 100644 index 000000000..14f1d613e --- /dev/null +++ b/lib/components/library/playlist_generate/multi_select_field.dart @@ -0,0 +1,271 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fuzzywuzzy/fuzzywuzzy.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; + +class MultiSelectField extends HookWidget { + final List options; + final List selectedOptions; + + final Widget Function(T option, VoidCallback onSelect)? optionBuilder; + final Widget Function(T option)? selectedOptionBuilder; + final ValueChanged> onSelected; + + final Widget? dialogTitle; + + final Object Function(T option) getValueForOption; + + final Widget label; + + final String? helperText; + + final bool enabled; + + const MultiSelectField({ + Key? key, + required this.options, + required this.selectedOptions, + required this.getValueForOption, + required this.label, + this.optionBuilder, + this.selectedOptionBuilder, + required this.onSelected, + this.dialogTitle, + this.helperText, + this.enabled = true, + }) : super(key: key); + + Widget defaultSelectedOptionBuilder(T option) { + return Chip( + label: Text(option.toString()), + onDeleted: () { + onSelected( + selectedOptions.where((e) => e != getValueForOption(option)).toList(), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MaterialButton( + elevation: 0, + focusElevation: 0, + hoverElevation: 0, + disabledElevation: 0, + highlightElevation: 0, + padding: const EdgeInsets.symmetric(vertical: 22), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + side: BorderSide( + color: enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withOpacity(0.1), + ), + ), + mouseCursor: MaterialStateMouseCursor.textable, + onPressed: !enabled + ? null + : () async { + final selected = await showDialog>( + context: context, + builder: (context) { + return _MultiSelectDialog( + dialogTitle: dialogTitle, + options: options, + getValueForOption: getValueForOption, + optionBuilder: optionBuilder, + initialSelection: selectedOptions, + helperText: helperText, + ); + }, + ); + if (selected != null) { + onSelected(selected); + } + }, + child: Container( + alignment: Alignment.centerLeft, + margin: const EdgeInsets.symmetric(horizontal: 10), + child: DefaultTextStyle( + style: theme.textTheme.titleMedium!, + child: label, + ), + ), + ), + if (helperText != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + helperText!, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + Wrap( + children: [ + ...selectedOptions.map( + (option) => Padding( + padding: const EdgeInsets.all(4.0), + child: (selectedOptionBuilder ?? + defaultSelectedOptionBuilder)(option), + ), + ), + ], + ) + ], + ); + } +} + +class _MultiSelectDialog extends HookWidget { + final Widget? dialogTitle; + final List options; + final Widget Function(T option, VoidCallback onSelect)? optionBuilder; + final Object Function(T option) getValueForOption; + final List initialSelection; + final String? helperText; + + const _MultiSelectDialog({ + Key? key, + required this.dialogTitle, + required this.options, + required this.getValueForOption, + this.optionBuilder, + this.initialSelection = const [], + this.helperText, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + final selected = useState(initialSelection.map(getValueForOption)); + + final searchController = useTextEditingController(); + + // creates render update + useValueListenable(searchController); + + final filteredOptions = useMemoized( + () { + if (searchController.text.isEmpty) { + return options; + } + + return options + .map((e) => ( + weightedRatio( + getValueForOption(e).toString(), searchController.text), + e + )) + .sorted((a, b) => b.$1.compareTo(a.$1)) + .where((e) => e.$1 > 50) + .map((e) => e.$2) + .toList(); + }, + [searchController.text, options, getValueForOption], + ); + + Widget defaultOptionBuilder(T option, VoidCallback onSelect) { + final isSelected = selected.value.contains(getValueForOption(option)); + return ChoiceChip( + label: Text("${!isSelected ? " " : ""}${option.toString()}"), + selected: isSelected, + side: BorderSide.none, + onSelected: (_) { + onSelect(); + }, + ); + } + + return AlertDialog( + scrollable: true, + title: dialogTitle ?? const Text('Select'), + contentPadding: mediaQuery.isSm ? const EdgeInsets.all(16) : null, + insetPadding: const EdgeInsets.all(16), + actions: [ + OutlinedButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text(context.l10n.cancel), + ), + ElevatedButton( + onPressed: () { + Navigator.pop( + context, + options + .where( + (option) => + selected.value.contains(getValueForOption(option)), + ) + .toList(), + ); + }, + child: Text(context.l10n.done), + ), + ], + content: SizedBox( + height: mediaQuery.size.height * 0.5, + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: searchController, + decoration: InputDecoration( + hintText: context.l10n.search, + prefixIcon: const Icon(SpotubeIcons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + ), + ), + ), + const SizedBox(height: 10), + Expanded( + child: SingleChildScrollView( + child: Wrap( + spacing: 5, + runSpacing: 5, + children: [ + ...filteredOptions.map( + (option) => Padding( + padding: const EdgeInsets.all(4.0), + child: (optionBuilder ?? defaultOptionBuilder)( + option, + () { + final value = getValueForOption(option); + if (selected.value.contains(value)) { + selected.value = selected.value + .where((e) => e != value) + .toList(); + } else { + selected.value = [...selected.value, value]; + } + }, + ), + ), + ), + ], + ), + ), + ), + if (helperText != null) + Text( + helperText!, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ), + ); + } +} diff --git a/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart b/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart new file mode 100644 index 000000000..cb6b0cf75 --- /dev/null +++ b/lib/components/library/playlist_generate/seeds_multi_autocomplete.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +enum SelectedItemDisplayType { + wrap, + list, +} + +class SeedsMultiAutocomplete extends HookWidget { + final ValueNotifier> seeds; + + final FutureOr> Function(TextEditingValue textEditingValue) + fetchSeeds; + final Widget Function(T option, ValueChanged onSelected) + autocompleteOptionBuilder; + final Widget Function(T option) selectedSeedBuilder; + final String Function(T option) displayStringForOption; + + final InputDecoration? inputDecoration; + final bool enabled; + + final SelectedItemDisplayType selectedItemDisplayType; + + const SeedsMultiAutocomplete({ + Key? key, + required this.seeds, + required this.fetchSeeds, + required this.autocompleteOptionBuilder, + required this.displayStringForOption, + required this.selectedSeedBuilder, + this.inputDecoration, + this.enabled = true, + this.selectedItemDisplayType = SelectedItemDisplayType.wrap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + useValueListenable(seeds); + final theme = Theme.of(context); + final seedController = useTextEditingController(); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LayoutBuilder(builder: (context, constrains) { + return Autocomplete( + optionsBuilder: (textEditingValue) async { + if (textEditingValue.text.isEmpty) return []; + return fetchSeeds(textEditingValue); + }, + onSelected: (value) { + seeds.value = [...seeds.value, value]; + seedController.clear(); + }, + optionsViewBuilder: (context, onSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constrains.maxWidth, + ), + child: Card( + child: ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final option = options.elementAt(index); + return autocompleteOptionBuilder(option, onSelected); + }, + ), + ), + ), + ); + }, + displayStringForOption: displayStringForOption, + fieldViewBuilder: ( + context, + textEditingController, + focusNode, + onFieldSubmitted, + ) { + return TextFormField( + controller: seedController, + onChanged: (value) => textEditingController.text = value, + focusNode: focusNode, + onFieldSubmitted: (_) => onFieldSubmitted(), + enabled: enabled, + decoration: inputDecoration, + ); + }, + ); + }), + const SizedBox(height: 8), + switch (selectedItemDisplayType) { + SelectedItemDisplayType.wrap => Wrap( + spacing: 4, + runSpacing: 4, + children: seeds.value.map(selectedSeedBuilder).toList(), + ), + SelectedItemDisplayType.list => Card( + margin: EdgeInsets.zero, + child: Column( + children: [ + for (final seed in seeds.value) ...[ + selectedSeedBuilder(seed), + if (seeds.value.length > 1 && seed != seeds.value.last) + Divider( + color: theme.colorScheme.primaryContainer, + height: 1, + indent: 12, + endIndent: 12, + ), + ], + ], + ), + ), + }, + ], + ); + } +} diff --git a/lib/components/library/playlist_generate/simple_track_tile.dart b/lib/components/library/playlist_generate/simple_track_tile.dart new file mode 100644 index 000000000..86800d065 --- /dev/null +++ b/lib/components/library/playlist_generate/simple_track_tile.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotube_icons.dart'; + +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class SimpleTrackTile extends HookWidget { + final Track track; + final VoidCallback? onDelete; + const SimpleTrackTile({ + Key? key, + required this.track, + this.onDelete, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: UniversalImage( + path: TypeConversionUtils.image_X_UrlString( + track.album?.images, + placeholder: ImagePlaceholder.artist, + ), + height: 40, + width: 40, + ), + ), + horizontalTitleGap: 10, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + title: Text(track.name!), + trailing: onDelete == null + ? null + : IconButton( + icon: const Icon(SpotubeIcons.close), + onPressed: onDelete, + ), + subtitle: Text( + track.artists?.map((e) => e.name).join(", ") ?? track.album?.name ?? "", + ), + ); + } +} diff --git a/lib/components/library/user_playlists.dart b/lib/components/library/user_playlists.dart index 9fb2a8616..bf5ae918d 100644 --- a/lib/components/library/user_playlists.dart +++ b/lib/components/library/user_playlists.dart @@ -2,6 +2,7 @@ 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:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; @@ -11,8 +12,6 @@ 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/extensions/context.dart'; -import 'package:spotube/hooks/use_breakpoint_value.dart'; -import 'package:spotube/hooks/use_breakpoints.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/services/queries/queries.dart'; @@ -22,11 +21,7 @@ class UserPlaylists extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final searchText = useState(''); - final breakpoint = useBreakpoints(); - final spacing = useBreakpointValue( - sm: 0, - others: 20, - ); + final auth = ref.watch(AuthenticationNotifier.provider); final playlistsQuery = useQueries.playlist.ofMine(ref); @@ -103,10 +98,18 @@ class UserPlaylists extends HookConsumerWidget { alignment: WrapAlignment.center, children: [ Row( - children: const [ - SizedBox(width: 10), - PlaylistCreateDialog(), - SizedBox(width: 10), + children: [ + const SizedBox(width: 10), + const PlaylistCreateDialog(), + const SizedBox(width: 10), + ElevatedButton.icon( + icon: const Icon(SpotubeIcons.magic), + label: Text(context.l10n.generate_playlist), + onPressed: () { + GoRouter.of(context).push("/library/generate"); + }, + ), + const SizedBox(width: 10), ], ), ...playlists.map((playlist) => PlaylistCard(playlist)) diff --git a/lib/components/playlist/playlist_create_dialog.dart b/lib/components/playlist/playlist_create_dialog.dart index f61a662fa..c6fc8dd5a 100644 --- a/lib/components/playlist/playlist_create_dialog.dart +++ b/lib/components/playlist/playlist_create_dialog.dart @@ -2,110 +2,124 @@ import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/spotify_provider.dart'; class PlaylistCreateDialog extends HookConsumerWidget { const PlaylistCreateDialog({Key? key}) : super(key: key); - @override - Widget build(BuildContext context, ref) { - final spotify = ref.watch(spotifyProvider); - - return FilledButton.tonalIcon( - style: FilledButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.primary, - ), - icon: const Icon(SpotubeIcons.addFilled), - label: Text(context.l10n.create_playlist), - onPressed: () { - showDialog( - context: context, - builder: (context) { - return HookBuilder(builder: (context) { - final playlistName = useTextEditingController(); - final description = useTextEditingController(); - final public = useState(false); - final collaborative = useState(false); - final client = useQueryClient(); - final navigator = Navigator.of(context); + showPlaylistDialog(BuildContext context, SpotifyApi spotify) { + showDialog( + context: context, + builder: (context) { + return HookBuilder(builder: (context) { + final playlistName = useTextEditingController(); + final description = useTextEditingController(); + final public = useState(false); + final collaborative = useState(false); + final client = useQueryClient(); + final navigator = Navigator.of(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 client - .getQuery( - "current-user-playlists", - ) - ?.refresh(); - navigator.pop(); - } + 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 client + .getQuery( + "current-user-playlists", + ) + ?.refresh(); + navigator.pop(); + } - return AlertDialog( - title: Text(context.l10n.create_a_playlist), - actions: [ - OutlinedButton( - child: Text(context.l10n.cancel), - onPressed: () { - Navigator.pop(context); - }, + return AlertDialog( + title: Text(context.l10n.create_a_playlist), + actions: [ + OutlinedButton( + child: Text(context.l10n.cancel), + onPressed: () { + Navigator.pop(context); + }, + ), + FilledButton( + onPressed: onCreate, + child: Text(context.l10n.create), + ), + ], + content: Container( + width: MediaQuery.of(context).size.width, + constraints: const BoxConstraints(maxWidth: 500), + child: ListView( + shrinkWrap: true, + children: [ + TextField( + controller: playlistName, + decoration: InputDecoration( + hintText: context.l10n.name_of_playlist, + labelText: context.l10n.name_of_playlist, + ), ), - FilledButton( - onPressed: onCreate, - child: Text(context.l10n.create), + const SizedBox(height: 10), + TextField( + controller: description, + decoration: InputDecoration( + hintText: context.l10n.description, + ), + keyboardType: TextInputType.multiline, + maxLines: 5, ), - ], - content: Container( - width: MediaQuery.of(context).size.width, - constraints: const BoxConstraints(maxWidth: 500), - child: ListView( - shrinkWrap: true, - children: [ - TextField( - controller: playlistName, - decoration: InputDecoration( - hintText: context.l10n.name_of_playlist, - labelText: context.l10n.name_of_playlist, - ), - ), - const SizedBox(height: 10), - TextField( - controller: description, - decoration: InputDecoration( - hintText: context.l10n.description, - ), - keyboardType: TextInputType.multiline, - maxLines: 5, - ), - const SizedBox(height: 10), - CheckboxListTile( - title: Text(context.l10n.public), - value: public.value, - onChanged: (val) => public.value = val ?? false, - ), - const SizedBox(height: 10), - CheckboxListTile( - title: Text(context.l10n.collaborative), - value: collaborative.value, - onChanged: (val) => collaborative.value = val ?? false, - ), - ], + const SizedBox(height: 10), + CheckboxListTile( + title: Text(context.l10n.public), + value: public.value, + onChanged: (val) => public.value = val ?? false, ), - ), - ); - }); - }, - ); + const SizedBox(height: 10), + CheckboxListTile( + title: Text(context.l10n.collaborative), + value: collaborative.value, + onChanged: (val) => collaborative.value = val ?? false, + ), + ], + ), + ), + ); + }); }, ); } + + @override + Widget build(BuildContext context, ref) { + final mediaQuery = MediaQuery.of(context); + final spotify = ref.watch(spotifyProvider); + + if (mediaQuery.isSm) { + return ElevatedButton( + style: FilledButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.primary, + ), + child: const Icon(SpotubeIcons.addFilled), + onPressed: () => showPlaylistDialog(context, spotify), + ); + } + + return FilledButton.tonalIcon( + style: FilledButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.primary, + ), + icon: const Icon(SpotubeIcons.addFilled), + label: Text(context.l10n.create_playlist), + onPressed: () => showPlaylistDialog(context, spotify)); + } } diff --git a/lib/extensions/constrains.dart b/lib/extensions/constrains.dart new file mode 100644 index 000000000..361ec799c --- /dev/null +++ b/lib/extensions/constrains.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; + +extension ContainerBreakpoints on BoxConstraints { + bool get isSm => biggest.width <= 640; + bool get isMd => biggest.width > 640 && biggest.width <= 768; + bool get isLg => biggest.width > 768 && biggest.width <= 1024; + bool get isXl => biggest.width > 1024 && biggest.width <= 1280; + bool get is2Xl => biggest.width > 1280 && biggest.width <= 1536; + + bool get mdAndUp => isMd || isLg || isXl || is2Xl; + bool get lgAndUp => isLg || isXl || is2Xl; + bool get xlAndUp => isXl || is2Xl; +} + +extension ScreenBreakpoints on MediaQueryData { + bool get isSm => size.width <= 640; + bool get isMd => size.width > 640 && size.width <= 768; + bool get isLg => size.width > 768 && size.width <= 1024; + bool get isXl => size.width > 1024 && size.width <= 1280; + bool get is2Xl => size.width > 1280 && size.width <= 1536; + + bool get mdAndUp => isMd || isLg || isXl || is2Xl; + bool get lgAndUp => isLg || isXl || is2Xl; + bool get xlAndUp => isXl || is2Xl; +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index bf6533ebb..5492ce384 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -184,5 +184,6 @@ "step_4_steps": "Paste the copied \"sp_dc\" and \"sp_key\" values in the respective fields", "something_went_wrong": "Something went wrong", "piped_instance": "Piped Server Instance", - "piped_description": "The Piped server instance to use for track matching\nSome of them might not work well. So use at your own risk" + "piped_description": "The Piped server instance to use for track matching\nSome of them might not work well. So use at your own risk", + "generate_playlist": "Generate Playlist" } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 4f971ce9d..a4443da1b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -119,7 +119,6 @@ Future main(List rawArgs) async { enableApplicationParameters: false, ), FileHandler(await getLogsPath(), printLogs: false), - CustomToastHandler(), ], ), releaseConfig: CatcherOptions( diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart new file mode 100644 index 000000000..f3a4473dd --- /dev/null +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -0,0 +1,296 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/collections/spotify_markets.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/library/playlist_generate/multi_select_field.dart'; +import 'package:spotube/components/library/playlist_generate/seeds_multi_autocomplete.dart'; +import 'package:spotube/components/library/playlist_generate/simple_track_tile.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart'; +import 'package:spotube/provider/spotify_provider.dart'; +import 'package:spotube/provider/user_preferences_provider.dart'; +import 'package:spotube/services/queries/queries.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class PlaylistGeneratorPage extends HookConsumerWidget { + const PlaylistGeneratorPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final spotify = ref.watch(spotifyProvider); + + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final preferences = ref.watch(userPreferencesProvider); + + final genresCollection = useQueries.category.genreSeeds(ref); + + final limit = useValueNotifier(10); + final market = useValueNotifier(preferences.recommendationMarket); + + final genres = useState>([]); + final artists = useState>([]); + final tracks = useState>([]); + + final enabled = + genres.value.length + artists.value.length + tracks.value.length < 5; + + final leftSeedCount = + 5 - genres.value.length - artists.value.length - tracks.value.length; + + return Scaffold( + appBar: PageWindowTitleBar( + leading: const BackButton(), + title: Text(context.l10n.generate_playlist), + centerTitle: true, + ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + ValueListenableBuilder( + valueListenable: limit, + builder: (context, value, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Number of tracks to generate", + style: textTheme.titleMedium, + ), + Row( + children: [ + Container( + width: 40, + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + child: Text( + value.round().toString(), + style: textTheme.bodyLarge?.copyWith( + color: theme.colorScheme.primaryContainer, + ), + ), + ), + Expanded( + child: Slider( + value: value.toDouble(), + min: 10, + max: 100, + divisions: 9, + label: value.round().toString(), + onChanged: (value) { + limit.value = value.round(); + }, + ), + ) + ], + ) + ], + ); + }, + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: market, + builder: (context, value, _) { + return DropdownMenu( + hintText: "Select a country", + dropdownMenuEntries: spotifyMarkets + .map( + (country) => DropdownMenuEntry( + value: country.first, + label: country.last, + ), + ) + .toList(), + initialSelection: market.value, + onSelected: (value) { + market.value = value!; + }, + ); + }, + ), + const SizedBox(height: 16), + MultiSelectField( + options: genresCollection.data ?? [], + selectedOptions: genres.value, + getValueForOption: (option) => option, + onSelected: (value) { + genres.value = value; + }, + dialogTitle: const Text("Select genres"), + label: const Text("Add genres"), + helperText: "Select up to $leftSeedCount genres", + enabled: enabled, + ), + const SizedBox(height: 16), + SeedsMultiAutocomplete( + seeds: artists, + enabled: enabled, + inputDecoration: InputDecoration( + labelText: "Artists", + labelStyle: textTheme.titleMedium, + helperText: "Select up to $leftSeedCount artists", + ), + fetchSeeds: (textEditingValue) => spotify.search + .get( + textEditingValue.text, + types: [SearchType.artist], + ) + .first(6) + .then( + (v) => List.castFrom( + v.expand((e) => e.items ?? []).toList(), + ) + .where( + (element) => artists.value + .none((artist) => element.id == artist.id), + ) + .toList(), + ), + autocompleteOptionBuilder: (option, onSelected) => ListTile( + leading: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + TypeConversionUtils.image_X_UrlString( + option.images, + placeholder: ImagePlaceholder.artist, + ), + ), + ), + horizontalTitleGap: 20, + title: Text(option.name!), + subtitle: option.genres?.isNotEmpty != true + ? null + : Wrap( + spacing: 4, + runSpacing: 4, + children: option.genres!.mapIndexed( + (index, genre) { + return Chip( + label: Text(genre), + labelStyle: textTheme.bodySmall?.copyWith( + color: theme.colorScheme.secondary, + fontWeight: FontWeight.w600, + ), + side: BorderSide.none, + backgroundColor: + theme.colorScheme.secondaryContainer, + ); + }, + ).toList(), + ), + onTap: () => onSelected(option), + ), + displayStringForOption: (option) => option.name!, + selectedSeedBuilder: (artist) => Chip( + avatar: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + TypeConversionUtils.image_X_UrlString( + artist.images, + placeholder: ImagePlaceholder.artist, + ), + ), + ), + label: Text(artist.name!), + onDeleted: () { + artists.value = [ + ...artists.value + ..removeWhere((element) => element.id == artist.id) + ]; + }, + ), + ), + const SizedBox(height: 16), + SeedsMultiAutocomplete( + seeds: tracks, + enabled: enabled, + selectedItemDisplayType: SelectedItemDisplayType.list, + inputDecoration: InputDecoration( + labelText: "Tracks", + labelStyle: textTheme.titleMedium, + helperText: "Select up to $leftSeedCount tracks", + ), + fetchSeeds: (textEditingValue) => spotify.search + .get( + textEditingValue.text, + types: [SearchType.track], + ) + .first(6) + .then( + (v) => List.castFrom( + v.expand((e) => e.items ?? []).toList(), + ) + .where( + (element) => tracks.value + .none((track) => element.id == track.id), + ) + .toList(), + ), + autocompleteOptionBuilder: (option, onSelected) => ListTile( + leading: CircleAvatar( + backgroundImage: UniversalImage.imageProvider( + TypeConversionUtils.image_X_UrlString( + option.album?.images, + placeholder: ImagePlaceholder.artist, + ), + ), + ), + horizontalTitleGap: 20, + title: Text(option.name!), + subtitle: Text( + option.artists?.map((e) => e.name).join(", ") ?? + option.album?.name ?? + "", + ), + onTap: () => onSelected(option), + ), + displayStringForOption: (option) => option.name!, + selectedSeedBuilder: (option) => SimpleTrackTile( + track: option, + onDelete: () { + tracks.value = [ + ...tracks.value + ..removeWhere((element) => element.id == option.id) + ]; + }, + ), + ), + const SizedBox(height: 20), + FilledButton.icon( + icon: const Icon(SpotubeIcons.magic), + label: Text("Generate"), + onPressed: () { + final PlaylistGenerateResultRouteState routeState = ( + seeds: ( + artists: artists.value.map((a) => a.id!).toList(), + tracks: tracks.value.map((t) => t.id!).toList(), + genres: genres.value + ), + market: market.value, + limit: limit.value, + max: null, + min: null, + target: null, + ); + GoRouter.of(context).push( + "/library/generate/result", + extra: routeState, + ); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart new file mode 100644 index 000000000..db964166c --- /dev/null +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -0,0 +1,202 @@ +import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/library/playlist_generate/simple_track_tile.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/services/queries/playlist.dart'; +import 'package:spotube/services/queries/queries.dart'; + +typedef PlaylistGenerateResultRouteState = ({ + ({List tracks, List artists, List genres})? seeds, + RecommendationParameters? min, + RecommendationParameters? max, + RecommendationParameters? target, + int limit, + String? market, +}); + +class PlaylistGenerateResultPage extends HookConsumerWidget { + final PlaylistGenerateResultRouteState state; + + const PlaylistGenerateResultPage({ + Key? key, + required this.state, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); + final (:seeds, :min, :max, :target, :limit, :market) = state; + + final queryClient = useQueryClient(); + final generatedPlaylist = useQueries.playlist.generate( + ref, + seeds: seeds, + min: min, + max: max, + target: target, + limit: limit, + market: market, + ); + + final selectedTracks = useState>( + generatedPlaylist.data?.map((e) => e.id!).toList() ?? [], + ); + + useEffect(() { + if (generatedPlaylist.data != null) { + selectedTracks.value = + generatedPlaylist.data!.map((e) => e.id!).toList(); + } + return null; + }, [generatedPlaylist.data]); + + final isAllTrackSelected = + selectedTracks.value.length == (generatedPlaylist.data?.length ?? 0); + + return WillPopScope( + onWillPop: () async { + queryClient.cache.removeQuery(generatedPlaylist); + return true; + }, + child: Scaffold( + appBar: const PageWindowTitleBar(leading: BackButton()), + body: generatedPlaylist.isLoading + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CircularProgressIndicator(), + Text("Generating your custom playlist..."), + ], + ), + ) + : Padding( + padding: const EdgeInsets.all(8.0), + child: ListView( + children: [ + GridView( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + mainAxisExtent: 32, + ), + shrinkWrap: true, + children: [ + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.play), + label: Text(context.l10n.play), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.load( + generatedPlaylist.data!.where( + (e) => + selectedTracks.value.contains(e.id!), + ), + autoPlay: true, + ); + }, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.queueAdd), + label: Text(context.l10n.add_to_queue), + onPressed: selectedTracks.value.isEmpty + ? null + : () async { + await playlistNotifier.addTracks( + generatedPlaylist.data!.where( + (e) => + selectedTracks.value.contains(e.id!), + ), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.add_count_to_queue( + selectedTracks.value.length, + )), + ), + ); + } + }, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.addFilled), + label: Text(context.l10n.create_a_playlist), + onPressed: + selectedTracks.value.isEmpty ? null : () {}, + ), + FilledButton.tonalIcon( + icon: const Icon(SpotubeIcons.playlistAdd), + label: Text(context.l10n.add_to_playlist), + onPressed: + selectedTracks.value.isEmpty ? null : () {}, + ) + ], + ), + const SizedBox(height: 16), + if (generatedPlaylist.data != null) + Align( + alignment: Alignment.centerRight, + child: ElevatedButton.icon( + onPressed: () { + if (isAllTrackSelected) { + selectedTracks.value = []; + } else { + selectedTracks.value = generatedPlaylist.data + ?.map((e) => e.id!) + .toList() ?? + []; + } + }, + icon: const Icon(SpotubeIcons.selectionCheck), + label: Text( + isAllTrackSelected ? "Deselect all" : "Select all", + ), + ), + ), + const SizedBox(height: 8), + Card( + margin: const EdgeInsets.all(0), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final track in generatedPlaylist.data ?? []) + CheckboxListTile( + value: selectedTracks.value.contains(track.id), + onChanged: (value) { + if (value == true) { + selectedTracks.value.add(track.id!); + } else { + selectedTracks.value.remove(track.id); + } + selectedTracks.value = + selectedTracks.value.toList(); + }, + controlAffinity: + ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + title: SimpleTrackTile(track: track), + ) + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart index 9e4ce60ef..cfd291e15 100644 --- a/lib/services/custom_spotify_endpoints/spotify_endpoints.dart +++ b/lib/services/custom_spotify_endpoints/spotify_endpoints.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; +import 'package:spotify/spotify.dart'; class CustomSpotifyEndpoints { static const _baseUrl = 'https://api.spotify.com/v1'; @@ -80,4 +81,83 @@ class CustomSpotifyEndpoints { ); } } + + Future> listGenreSeeds() async { + final res = await _client.get( + Uri.parse("$_baseUrl/recommendations/available-genre-seeds"), + headers: { + "content-type": "application/json", + if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", + "accept": "application/json", + }, + ); + + if (res.statusCode == 200) { + final body = jsonDecode(res.body); + return List.from(body["genres"] ?? []); + } else { + throw Exception( + '[CustomSpotifyEndpoints.listGenreSeeds]: Failed to get genre seeds' + '\nStatus code: ${res.statusCode}' + '\nBody: ${res.body}', + ); + } + } + + void _addList( + Map parameters, String key, Iterable paramList) { + if (paramList.isNotEmpty) { + parameters[key] = paramList.join(','); + } + } + + void _addTunableTrackMap( + Map parameters, Map? tunableTrackMap) { + if (tunableTrackMap != null) { + parameters.addAll(tunableTrackMap.map((k, v) => + MapEntry(k, v is int ? v.toString() : v.toStringAsFixed(2)))); + } + } + + Future> getRecommendations({ + Iterable? seedArtists, + Iterable? seedGenres, + Iterable? seedTracks, + int limit = 20, + String? market, + Map? max, + Map? min, + Map? target, + }) async { + assert(limit >= 1 && limit <= 100, 'limit should be 1 <= limit <= 100'); + final seedsNum = (seedArtists?.length ?? 0) + + (seedGenres?.length ?? 0) + + (seedTracks?.length ?? 0); + assert( + seedsNum >= 1 && seedsNum <= 5, + 'Up to 5 seed values may be provided in any combination of seed_artists,' + ' seed_tracks and seed_genres.'); + final parameters = {'limit': limit.toString()}; + final _ = { + 'seed_artists': seedArtists, + 'seed_genres': seedGenres, + 'seed_tracks': seedTracks + }.forEach((key, list) => _addList(parameters, key, list!)); + if (market != null) parameters['market'] = market; + [min, max, target].forEach((map) => _addTunableTrackMap(parameters, map)); + final pathQuery = + "$_baseUrl/recommendations?${parameters.entries.map((e) => '${e.key}=${e.value}').join('&')}"; + final res = await _client.get( + Uri.parse(pathQuery), + headers: { + "content-type": "application/json", + if (accessToken.isNotEmpty) "authorization": "Bearer $accessToken", + "accept": "application/json", + }, + ); + final result = jsonDecode(res.body); + return List.castFrom( + result["tracks"].map((track) => Track.fromJson(track)).toList(), + ); + } } diff --git a/lib/services/queries/category.dart b/lib/services/queries/category.dart index 168307901..91513fd70 100644 --- a/lib/services/queries/category.dart +++ b/lib/services/queries/category.dart @@ -1,9 +1,11 @@ import 'package:fl_query/fl_query.dart'; +import 'package:fl_query_hooks/fl_query_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/use_spotify_infinite_query.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; class CategoryQueries { @@ -69,4 +71,25 @@ class CategoryQueries { ref: ref, ); } + + Query, dynamic> genreSeeds(WidgetRef ref) { + final customSpotify = ref.watch(customSpotifyEndpointProvider); + final query = useQuery, dynamic>( + "genre-seeds", + customSpotify.listGenreSeeds, + ); + + useEffect(() { + return ref.listenManual( + customSpotifyEndpointProvider, + (previous, next) { + if (previous != next) { + query.refresh(); + } + }, + ).close; + }, [query]); + + return query; + } } diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart index 06f8d952b..1afd80112 100644 --- a/lib/services/queries/playlist.dart +++ b/lib/services/queries/playlist.dart @@ -1,11 +1,53 @@ +import 'dart:convert'; + import 'package:catcher/catcher.dart'; import 'package:fl_query/fl_query.dart'; +import 'package:fl_query_hooks/fl_query_hooks.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/map.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/hooks/use_spotify_infinite_query.dart'; import 'package:spotube/hooks/use_spotify_query.dart'; +import 'package:spotube/provider/custom_spotify_endpoint_provider.dart'; +import 'package:spotube/provider/user_preferences_provider.dart'; + +typedef RecommendationParameters = ({ + double acousticness, + double danceability, + double duration_ms, + double energy, + double instrumentalness, + double key, + double liveness, + double loudness, + double mode, + double popularity, + double speechiness, + double tempo, + double time_signature, + double valence, +}); + +Map recommendationParametersToMap( + RecommendationParameters params) => + { + "acousticness": params.acousticness, + "danceability": params.danceability, + "duration_ms": params.duration_ms, + "energy": params.energy, + "instrumentalness": params.instrumentalness, + "key": params.key, + "liveness": params.liveness, + "loudness": params.loudness, + "mode": params.mode, + "popularity": params.popularity, + "speechiness": params.speechiness, + "tempo": params.tempo, + "time_signature": params.time_signature, + "valence": params.valence, + }; class PlaylistQueries { const PlaylistQueries(); @@ -94,4 +136,49 @@ class PlaylistQueries { ref: ref, ); } + + Query, dynamic> generate( + WidgetRef ref, { + ({List tracks, List artists, List genres})? seeds, + RecommendationParameters? min, + RecommendationParameters? max, + RecommendationParameters? target, + int limit = 20, + String? market, + }) { + final marketOfPreference = ref.watch( + userPreferencesProvider.select((s) => s.recommendationMarket), + ); + final customSpotify = ref.watch(customSpotifyEndpointProvider); + + final query = useQuery, dynamic>( + "generate-playlist", + () async { + final tracks = await customSpotify.getRecommendations( + limit: limit, + market: market ?? marketOfPreference, + max: max != null ? recommendationParametersToMap(max) : null, + min: min != null ? recommendationParametersToMap(min) : null, + target: target != null ? recommendationParametersToMap(target) : null, + seedArtists: seeds?.artists, + seedGenres: seeds?.genres, + seedTracks: seeds?.tracks, + ); + return tracks; + }, + ); + + useEffect(() { + return ref.listenManual( + customSpotifyEndpointProvider, + (previous, next) { + if (previous != next) { + query.refresh(); + } + }, + ).close; + }, [query]); + + return query; + } } diff --git a/lib/utils/custom_toast_handler.dart b/lib/utils/custom_toast_handler.dart index b8d3bf3e5..fa7259622 100644 --- a/lib/utils/custom_toast_handler.dart +++ b/lib/utils/custom_toast_handler.dart @@ -30,7 +30,7 @@ class CustomToastHandler extends ReportHandler { ), ), dismissable: true, - toastDuration: const Duration(seconds: 10), + toastDuration: const Duration(seconds: 5), borderRadius: 10, ).show(context); return true;