From 9877d5f51736db03d5839dadf164d11d0cce82f0 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 8 Jun 2023 12:49:08 +0600 Subject: [PATCH] feat: playlist generation all parameters support --- .vscode/settings.json | 4 + .../playlist_generate/multi_select_field.dart | 1 + .../recommendation_attribute_dials.dart | 182 +++++++++++++++++ .../recommendation_attribute_fields.dart | 179 +++++++++++++++++ lib/l10n/app_en.arb | 28 ++- .../playlist_generate/playlist_generate.dart | 184 ++++++++++++++++-- .../playlist_generate_result.dart | 10 +- lib/services/queries/playlist.dart | 138 +++++++++---- 8 files changed, 666 insertions(+), 60 deletions(-) create mode 100644 lib/components/library/playlist_generate/recommendation_attribute_dials.dart create mode 100644 lib/components/library/playlist_generate/recommendation_attribute_fields.dart diff --git a/.vscode/settings.json b/.vscode/settings.json index 1b5691af3..fa4f1f51e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,11 @@ { "cmake.configureOnOpen": false, "cSpell.words": [ + "acousticness", + "danceability", + "instrumentalness", "Mpris", + "speechiness", "Spotube", "winget" ] diff --git a/lib/components/library/playlist_generate/multi_select_field.dart b/lib/components/library/playlist_generate/multi_select_field.dart index 14f1d613e..d8de3da58 100644 --- a/lib/components/library/playlist_generate/multi_select_field.dart +++ b/lib/components/library/playlist_generate/multi_select_field.dart @@ -220,6 +220,7 @@ class _MultiSelectDialog extends HookWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( + autofocus: true, controller: searchController, decoration: InputDecoration( hintText: context.l10n.search, diff --git a/lib/components/library/playlist_generate/recommendation_attribute_dials.dart b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart new file mode 100644 index 000000000..c81e6856e --- /dev/null +++ b/lib/components/library/playlist_generate/recommendation_attribute_dials.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; + +typedef RecommendationAttribute = ({double min, double target, double max}); + +RecommendationAttribute lowValues(double base) => + (min: 1 * base, target: 0.3 * base, max: 0.3 * base); +RecommendationAttribute moderateValues(double base) => + (min: 0.5 * base, target: 1 * base, max: 0.5 * base); +RecommendationAttribute highValues(double base) => + (min: 0.3 * base, target: 0.3 * base, max: 1 * base); + +class RecommendationAttributeDials extends HookWidget { + final Widget title; + final RecommendationAttribute values; + final ValueChanged onChanged; + final double base; + + const RecommendationAttributeDials({ + Key? key, + required this.values, + required this.onChanged, + required this.title, + this.base = 1, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final animation = useAnimationController( + duration: const Duration(milliseconds: 300), + ); + final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w500, + ); + + final minSlider = Row( + children: [ + Text(context.l10n.min, style: labelStyle), + Expanded( + child: Slider( + value: values.min / base, + min: 0, + max: 1, + onChanged: (value) => onChanged(( + min: value * base, + target: values.target, + max: values.max, + )), + ), + ), + ], + ); + + final targetSlider = Row( + children: [ + Text(context.l10n.target, style: labelStyle), + Expanded( + child: Slider( + value: values.target / base, + min: 0, + max: 1, + onChanged: (value) => onChanged(( + min: values.min, + target: value * base, + max: values.max, + )), + ), + ), + ], + ); + + final maxSlider = Row( + children: [ + Text(context.l10n.max, style: labelStyle), + Expanded( + child: Slider( + value: values.max / base, + min: 0, + max: 1, + onChanged: (value) => onChanged(( + min: values.min, + target: values.target, + max: value * base, + )), + ), + ), + ], + ); + + return LayoutBuilder(builder: (context, constrain) { + return Card( + child: ExpansionTile( + title: DefaultTextStyle( + style: Theme.of(context).textTheme.titleMedium!, + child: title, + ), + shape: const Border(), + leading: AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Transform.rotate( + angle: (animation.value * 3.14) / 2, + child: child, + ); + }, + child: const Icon(Icons.chevron_right), + ), + trailing: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: ToggleButtons( + borderRadius: BorderRadius.circular(8), + textStyle: labelStyle, + isSelected: [ + values == lowValues(base), + values == moderateValues(base), + values == highValues(base), + ], + onPressed: (index) { + RecommendationAttribute newValues = zeroValues; + switch (index) { + case 0: + newValues = lowValues(base); + break; + case 1: + newValues = moderateValues(base); + break; + case 2: + newValues = highValues(base); + break; + } + + if (newValues == values) { + onChanged(zeroValues); + } else { + onChanged(newValues); + } + }, + children: [ + Text(context.l10n.low), + Text(" ${context.l10n.moderate} "), + Text(context.l10n.high), + ], + ), + ), + onExpansionChanged: (value) { + if (value) { + animation.forward(); + } else { + animation.reverse(); + } + }, + children: [ + if (constrain.mdAndUp) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox(width: 16), + Expanded(child: minSlider), + Expanded(child: targetSlider), + Expanded(child: maxSlider), + ], + ) + else + Padding( + padding: const EdgeInsets.only(left: 16), + child: Column( + children: [ + minSlider, + targetSlider, + maxSlider, + ], + ), + ), + ], + ), + ); + }); + } +} diff --git a/lib/components/library/playlist_generate/recommendation_attribute_fields.dart b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart new file mode 100644 index 000000000..78bffbf2f --- /dev/null +++ b/lib/components/library/playlist_generate/recommendation_attribute_fields.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:spotube/components/library/playlist_generate/recommendation_attribute_dials.dart'; +import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; + +class RecommendationAttributeFields extends HookWidget { + final Widget title; + final RecommendationAttribute values; + final ValueChanged onChanged; + final Map? presets; + + const RecommendationAttributeFields({ + Key? key, + required this.values, + required this.onChanged, + required this.title, + this.presets, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final animation = useAnimationController( + duration: const Duration(milliseconds: 300), + ); + final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w500, + ); + + final minController = useTextEditingController(text: values.min.toString()); + final targetController = + useTextEditingController(text: values.target.toString()); + final maxController = useTextEditingController(text: values.max.toString()); + + useEffect(() { + listener() { + onChanged(( + min: double.tryParse(minController.text) ?? 0, + target: double.tryParse(targetController.text) ?? 0, + max: double.tryParse(maxController.text) ?? 0, + )); + } + + minController.addListener(listener); + targetController.addListener(listener); + maxController.addListener(listener); + + return () { + minController.removeListener(listener); + targetController.removeListener(listener); + maxController.removeListener(listener); + }; + }, [values]); + + final minField = TextField( + controller: minController, + decoration: InputDecoration( + labelText: context.l10n.min, + isDense: true, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: false, + signed: true, + ), + ); + + final targetField = TextField( + controller: targetController, + decoration: InputDecoration( + labelText: context.l10n.target, + isDense: true, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: false, + signed: true, + ), + ); + + final maxField = TextField( + controller: maxController, + decoration: InputDecoration( + labelText: context.l10n.max, + isDense: true, + ), + keyboardType: const TextInputType.numberWithOptions( + decimal: false, + signed: true, + ), + ); + + return LayoutBuilder(builder: (context, constrain) { + return Card( + child: ExpansionTile( + title: DefaultTextStyle( + style: Theme.of(context).textTheme.titleMedium!, + child: title, + ), + shape: const Border(), + leading: AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Transform.rotate( + angle: (animation.value * 3.14) / 2, + child: child, + ); + }, + child: const Icon(Icons.chevron_right), + ), + trailing: presets == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: ToggleButtons( + borderRadius: BorderRadius.circular(8), + textStyle: labelStyle, + isSelected: presets!.values + .map((value) => value == values) + .toList(), + onPressed: (index) { + RecommendationAttribute newValues = + presets!.values.elementAt(index); + if (newValues == values) { + onChanged(zeroValues); + minController.text = zeroValues.min.toString(); + targetController.text = zeroValues.target.toString(); + maxController.text = zeroValues.max.toString(); + } else { + onChanged(newValues); + minController.text = newValues.min.toString(); + targetController.text = newValues.target.toString(); + maxController.text = newValues.max.toString(); + } + }, + children: presets!.keys.map((key) => Text(key)).toList(), + ), + ), + onExpansionChanged: (value) { + if (value) { + animation.forward(); + } else { + animation.reverse(); + } + }, + children: [ + const SizedBox(height: 8), + if (constrain.mdAndUp) + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + const SizedBox(width: 16), + Expanded(child: minField), + const SizedBox(width: 16), + Expanded(child: targetField), + const SizedBox(width: 16), + Expanded(child: maxField), + const SizedBox(width: 16), + ], + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + minField, + const SizedBox(height: 16), + targetField, + const SizedBox(height: 16), + maxField, + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ); + }); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 60b5b3393..70bd18db5 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -191,5 +191,31 @@ "skip_download_tracks": "Skip downloading all downloaded tracks", "do_you_want_to_replace": "Do you want to replace the existing track??", "replace": "Replace", - "skip": "Skip" + "skip": "Skip", + "select_up_to_count_type": "Select up to {count} {type}", + "select_genres": "Select Genres", + "add_genres": "Add Genres", + "country": "Country", + "number_of_tracks_generate": "Number of tracks to generate", + "acousticness": "Acousticness", + "danceability": "Danceability", + "energy": "Energy", + "instrumentalness": "Instrumentalness", + "liveness": "Liveness", + "loudness": "Loudness", + "speechiness": "Speechiness", + "valence": "Valence", + "popularity": "Popularity", + "key": "Key", + "duration": "Duration (s)", + "tempo": "Tempo (BPM)", + "mode": "Mode", + "time_signature": "Time Signature", + "short": "Short", + "medium": "Medium", + "long": "Long", + "min": "Min", + "max": "Max", + "target": "Target", + "moderate": "Moderate" } \ No newline at end of file diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index 1bc8b23de..c183dce5c 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -7,6 +7,8 @@ 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/recommendation_attribute_dials.dart'; +import 'package:spotube/components/library/playlist_generate/recommendation_attribute_fields.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'; @@ -19,6 +21,8 @@ import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; +const RecommendationAttribute zeroValues = (min: 0, target: 0, max: 0); + class PlaylistGeneratorPage extends HookConsumerWidget { const PlaylistGeneratorPage({Key? key}) : super(key: key); @@ -45,13 +49,34 @@ class PlaylistGeneratorPage extends HookConsumerWidget { final leftSeedCount = 5 - genres.value.length - artists.value.length - tracks.value.length; + // Dial (int 0-1) attributes + final acousticness = useState(zeroValues); + final danceability = useState(zeroValues); + final energy = useState(zeroValues); + final instrumentalness = useState(zeroValues); + final key = useState(zeroValues); + final liveness = useState(zeroValues); + final loudness = useState(zeroValues); + final popularity = useState(zeroValues); + final speechiness = useState(zeroValues); + final valence = useState(zeroValues); + + // Field editable attributes + final tempo = useState(zeroValues); + final durationMs = useState(zeroValues); + final mode = useState(zeroValues); + final timeSignature = useState(zeroValues); + final artistAutoComplete = SeedsMultiAutocomplete( seeds: artists, enabled: enabled, inputDecoration: InputDecoration( - labelText: "Artists", + labelText: context.l10n.artists, labelStyle: textTheme.titleMedium, - helperText: "Select up to $leftSeedCount artists", + helperText: context.l10n.select_up_to_count_type( + leftSeedCount, + context.l10n.artists, + ), ), fetchSeeds: (textEditingValue) => spotify.search .get( @@ -125,9 +150,12 @@ class PlaylistGeneratorPage extends HookConsumerWidget { enabled: enabled, selectedItemDisplayType: SelectedItemDisplayType.list, inputDecoration: InputDecoration( - labelText: "Tracks", + labelText: context.l10n.tracks, labelStyle: textTheme.titleMedium, - helperText: "Select up to $leftSeedCount tracks", + helperText: context.l10n.select_up_to_count_type( + leftSeedCount, + context.l10n.tracks, + ), ), fetchSeeds: (textEditingValue) => spotify.search .get( @@ -181,9 +209,12 @@ class PlaylistGeneratorPage extends HookConsumerWidget { onSelected: (value) { genres.value = value; }, - dialogTitle: const Text("Select genres"), - label: const Text("Add genres"), - helperText: "Select up to $leftSeedCount genres", + dialogTitle: Text(context.l10n.select_genres), + label: Text(context.l10n.add_genres), + helperText: context.l10n.select_up_to_count_type( + leftSeedCount, + context.l10n.genre, + ), enabled: enabled, ); final countrySelector = ValueListenableBuilder( @@ -191,7 +222,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { builder: (context, value, _) { return DropdownButtonFormField( decoration: InputDecoration( - labelText: "Country", + labelText: context.l10n.country, labelStyle: textTheme.titleMedium, ), isExpanded: true, @@ -229,7 +260,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Number of tracks to generate", + context.l10n.number_of_tracks_generate, style: textTheme.titleMedium, ), Row( @@ -305,10 +336,124 @@ class PlaylistGeneratorPage extends HookConsumerWidget { const SizedBox(height: 16), tracksAutocomplete, ], + const SizedBox(height: 16), + RecommendationAttributeDials( + title: Text(context.l10n.acousticness), + values: acousticness.value, + onChanged: (value) { + acousticness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.danceability), + values: danceability.value, + onChanged: (value) { + danceability.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.energy), + values: energy.value, + onChanged: (value) { + energy.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.instrumentalness), + values: instrumentalness.value, + onChanged: (value) { + instrumentalness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.liveness), + values: liveness.value, + onChanged: (value) { + liveness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.loudness), + values: loudness.value, + onChanged: (value) { + loudness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.speechiness), + values: speechiness.value, + onChanged: (value) { + speechiness.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.valence), + values: valence.value, + onChanged: (value) { + valence.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.popularity), + values: popularity.value, + base: 100, + onChanged: (value) { + popularity.value = value; + }, + ), + RecommendationAttributeDials( + title: Text(context.l10n.key), + values: key.value, + base: 11, + onChanged: (value) { + key.value = value; + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.duration), + values: ( + max: durationMs.value.max / 1000, + target: durationMs.value.target / 1000, + min: durationMs.value.min / 1000, + ), + onChanged: (value) { + durationMs.value = ( + max: value.max * 1000, + target: value.target * 1000, + min: value.min * 1000, + ); + }, + presets: { + context.l10n.short: (min: 50, target: 90, max: 120), + context.l10n.medium: (min: 120, target: 180, max: 200), + context.l10n.long: (min: 480, target: 560, max: 640) + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.tempo), + values: tempo.value, + onChanged: (value) { + tempo.value = value; + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.mode), + values: mode.value, + onChanged: (value) { + mode.value = value; + }, + ), + RecommendationAttributeFields( + title: Text(context.l10n.time_signature), + values: timeSignature.value, + onChanged: (value) { + timeSignature.value = value; + }, + ), const SizedBox(height: 20), FilledButton.icon( icon: const Icon(SpotubeIcons.magic), - label: Text("Generate"), + label: Text(context.l10n.generate_playlist), onPressed: () { final PlaylistGenerateResultRouteState routeState = ( seeds: ( @@ -318,9 +463,22 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ), market: market.value, limit: limit.value, - max: null, - min: null, - target: null, + parameters: ( + acousticness: acousticness.value, + danceability: danceability.value, + energy: energy.value, + instrumentalness: instrumentalness.value, + liveness: liveness.value, + loudness: loudness.value, + speechiness: speechiness.value, + valence: valence.value, + popularity: popularity.value, + key: key.value, + duration_ms: durationMs.value, + tempo: tempo.value, + mode: mode.value, + time_signature: timeSignature.value, + ) ); GoRouter.of(context).push( "/library/generate/result", diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index db964166c..1a21c67a2 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -12,9 +12,7 @@ import 'package:spotube/services/queries/queries.dart'; typedef PlaylistGenerateResultRouteState = ({ ({List tracks, List artists, List genres})? seeds, - RecommendationParameters? min, - RecommendationParameters? max, - RecommendationParameters? target, + RecommendationParameters? parameters, int limit, String? market, }); @@ -30,15 +28,13 @@ class PlaylistGenerateResultPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); - final (:seeds, :min, :max, :target, :limit, :market) = state; + final (:seeds, :parameters, :limit, :market) = state; final queryClient = useQueryClient(); final generatedPlaylist = useQueries.playlist.generate( ref, seeds: seeds, - min: min, - max: max, - target: target, + parameters: parameters, limit: limit, market: market, ); diff --git a/lib/services/queries/playlist.dart b/lib/services/queries/playlist.dart index 1afd80112..194e1e462 100644 --- a/lib/services/queries/playlist.dart +++ b/lib/services/queries/playlist.dart @@ -1,54 +1,113 @@ -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/components/library/playlist_generate/recommendation_attribute_dials.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/pages/library/playlist_generate/playlist_generate.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, + RecommendationAttribute acousticness, + RecommendationAttribute danceability, + RecommendationAttribute duration_ms, + RecommendationAttribute energy, + RecommendationAttribute instrumentalness, + RecommendationAttribute key, + RecommendationAttribute liveness, + RecommendationAttribute loudness, + RecommendationAttribute mode, + RecommendationAttribute popularity, + RecommendationAttribute speechiness, + RecommendationAttribute tempo, + RecommendationAttribute time_signature, + RecommendationAttribute 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, +Map recommendationAttributeToMap(RecommendationAttribute attr) => { + "min": attr.min, + "target": attr.target, + "max": attr.max, }; +({Map min, Map target, Map max}) + recommendationParametersToMap(RecommendationParameters params) { + final maxMap = { + if (params.acousticness != zeroValues) + "acousticness": params.acousticness.max, + if (params.danceability != zeroValues) + "danceability": params.danceability.max, + if (params.duration_ms != zeroValues) "duration_ms": params.duration_ms.max, + if (params.energy != zeroValues) "energy": params.energy.max, + if (params.instrumentalness != zeroValues) + "instrumentalness": params.instrumentalness.max, + if (params.key != zeroValues) "key": params.key.max, + if (params.liveness != zeroValues) "liveness": params.liveness.max, + if (params.loudness != zeroValues) "loudness": params.loudness.max, + if (params.mode != zeroValues) "mode": params.mode.max, + if (params.popularity != zeroValues) "popularity": params.popularity.max, + if (params.speechiness != zeroValues) "speechiness": params.speechiness.max, + if (params.tempo != zeroValues) "tempo": params.tempo.max, + if (params.time_signature != zeroValues) + "time_signature": params.time_signature.max, + if (params.valence != zeroValues) "valence": params.valence.max, + }; + final minMap = { + if (params.acousticness != zeroValues) + "acousticness": params.acousticness.min, + if (params.danceability != zeroValues) + "danceability": params.danceability.min, + if (params.duration_ms != zeroValues) "duration_ms": params.duration_ms.min, + if (params.energy != zeroValues) "energy": params.energy.min, + if (params.instrumentalness != zeroValues) + "instrumentalness": params.instrumentalness.min, + if (params.key != zeroValues) "key": params.key.min, + if (params.liveness != zeroValues) "liveness": params.liveness.min, + if (params.loudness != zeroValues) "loudness": params.loudness.min, + if (params.mode != zeroValues) "mode": params.mode.min, + if (params.popularity != zeroValues) "popularity": params.popularity.min, + if (params.speechiness != zeroValues) "speechiness": params.speechiness.min, + if (params.tempo != zeroValues) "tempo": params.tempo.min, + if (params.time_signature != zeroValues) + "time_signature": params.time_signature.min, + if (params.valence != zeroValues) "valence": params.valence.min, + }; + final targetMap = { + if (params.acousticness != zeroValues) + "acousticness": params.acousticness.target, + if (params.danceability != zeroValues) + "danceability": params.danceability.target, + if (params.duration_ms != zeroValues) + "duration_ms": params.duration_ms.target, + if (params.energy != zeroValues) "energy": params.energy.target, + if (params.instrumentalness != zeroValues) + "instrumentalness": params.instrumentalness.target, + if (params.key != zeroValues) "key": params.key.target, + if (params.liveness != zeroValues) "liveness": params.liveness.target, + if (params.loudness != zeroValues) "loudness": params.loudness.target, + if (params.mode != zeroValues) "mode": params.mode.target, + if (params.popularity != zeroValues) "popularity": params.popularity.target, + if (params.speechiness != zeroValues) + "speechiness": params.speechiness.target, + if (params.tempo != zeroValues) "tempo": params.tempo.target, + if (params.time_signature != zeroValues) + "time_signature": params.time_signature.target, + if (params.valence != zeroValues) "valence": params.valence.target, + }; + + return ( + max: maxMap, + min: minMap, + target: targetMap, + ); +} + class PlaylistQueries { const PlaylistQueries(); @@ -140,9 +199,7 @@ class PlaylistQueries { Query, dynamic> generate( WidgetRef ref, { ({List tracks, List artists, List genres})? seeds, - RecommendationParameters? min, - RecommendationParameters? max, - RecommendationParameters? target, + RecommendationParameters? parameters, int limit = 20, String? market, }) { @@ -151,15 +208,18 @@ class PlaylistQueries { ); final customSpotify = ref.watch(customSpotifyEndpointProvider); + final parametersMap = + parameters == null ? null : recommendationParametersToMap(parameters); + 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, + max: parametersMap?.max, + min: parametersMap?.min, + target: parametersMap?.target, seedArtists: seeds?.artists, seedGenres: seeds?.genres, seedTracks: seeds?.tracks,