feat: custom playlist generator

This commit is contained in:
Kingkor Roy Tirtho 2023-06-06 17:41:37 +06:00
parent bed0d3bd70
commit f4b0d134ca
16 changed files with 1301 additions and 109 deletions

View File

@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:spotify/spotify.dart' hide Search; import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/pages/home/home.dart'; 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/lyrics/mini_lyrics.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/settings/blacklist.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/settings/settings.dart';
import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart';
import '../pages/library/playlist_generate/playlist_generate_result.dart';
final rootNavigatorKey = Catcher.navigatorKey; final rootNavigatorKey = Catcher.navigatorKey;
final shellRouteNavigatorKey = GlobalKey<NavigatorState>(); final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
final router = GoRouter( final router = GoRouter(
@ -45,7 +48,23 @@ final router = GoRouter(
name: "Library", name: "Library",
pageBuilder: (context, state) => pageBuilder: (context, state) =>
const SpotubePage(child: LibraryPage()), 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( GoRoute(
path: "/lyrics", path: "/lyrics",
name: "Lyrics", name: "Lyrics",

View File

@ -80,4 +80,6 @@ abstract class SpotubeIcons {
static const language = FeatherIcons.globe; static const language = FeatherIcons.globe;
static const error = FeatherIcons.alertTriangle; static const error = FeatherIcons.alertTriangle;
static const piped = FeatherIcons.cloud; static const piped = FeatherIcons.cloud;
static const magic = Icons.auto_fix_high_outlined;
static const selectionCheck = Icons.checklist_rounded;
} }

View File

@ -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<T> extends HookWidget {
final List<T> options;
final List<T> selectedOptions;
final Widget Function(T option, VoidCallback onSelect)? optionBuilder;
final Widget Function(T option)? selectedOptionBuilder;
final ValueChanged<List<T>> 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<List<T>>(
context: context,
builder: (context) {
return _MultiSelectDialog<T>(
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<T> extends HookWidget {
final Widget? dialogTitle;
final List<T> options;
final Widget Function(T option, VoidCallback onSelect)? optionBuilder;
final Object Function(T option) getValueForOption;
final List<T> 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,
),
],
),
),
);
}
}

View File

@ -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<T extends Object> extends HookWidget {
final ValueNotifier<List<T>> seeds;
final FutureOr<List<T>> Function(TextEditingValue textEditingValue)
fetchSeeds;
final Widget Function(T option, ValueChanged<T> 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<T>(
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,
),
],
],
),
),
},
],
);
}
}

View File

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

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart' hide Image;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.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/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/playlist/playlist_card.dart'; import 'package:spotube/components/playlist/playlist_card.dart';
import 'package:spotube/extensions/context.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/provider/authentication_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
@ -22,11 +21,7 @@ class UserPlaylists extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final searchText = useState(''); final searchText = useState('');
final breakpoint = useBreakpoints();
final spacing = useBreakpointValue<double>(
sm: 0,
others: 20,
);
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(AuthenticationNotifier.provider);
final playlistsQuery = useQueries.playlist.ofMine(ref); final playlistsQuery = useQueries.playlist.ofMine(ref);
@ -103,10 +98,18 @@ class UserPlaylists extends HookConsumerWidget {
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: [ children: [
Row( Row(
children: const [ children: [
SizedBox(width: 10), const SizedBox(width: 10),
PlaylistCreateDialog(), const PlaylistCreateDialog(),
SizedBox(width: 10), 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)) ...playlists.map((playlist) => PlaylistCard(playlist))

View File

@ -2,25 +2,17 @@ import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
class PlaylistCreateDialog extends HookConsumerWidget { class PlaylistCreateDialog extends HookConsumerWidget {
const PlaylistCreateDialog({Key? key}) : super(key: key); const PlaylistCreateDialog({Key? key}) : super(key: key);
@override showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
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( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
@ -105,7 +97,29 @@ class PlaylistCreateDialog extends HookConsumerWidget {
}); });
}, },
); );
}, }
@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));
}
} }

View File

@ -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;
}

View File

@ -184,5 +184,6 @@
"step_4_steps": "Paste the copied \"sp_dc\" and \"sp_key\" values in the respective fields", "step_4_steps": "Paste the copied \"sp_dc\" and \"sp_key\" values in the respective fields",
"something_went_wrong": "Something went wrong", "something_went_wrong": "Something went wrong",
"piped_instance": "Piped Server Instance", "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"
} }

View File

@ -119,7 +119,6 @@ Future<void> main(List<String> rawArgs) async {
enableApplicationParameters: false, enableApplicationParameters: false,
), ),
FileHandler(await getLogsPath(), printLogs: false), FileHandler(await getLogsPath(), printLogs: false),
CustomToastHandler(),
], ],
), ),
releaseConfig: CatcherOptions( releaseConfig: CatcherOptions(

View File

@ -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<int>(10);
final market = useValueNotifier<String>(preferences.recommendationMarket);
final genres = useState<List<String>>([]);
final artists = useState<List<Artist>>([]);
final tracks = useState<List<Track>>([]);
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<String>(
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<String>(
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<Artist>(
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<dynamic, Artist>(
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<Track>(
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<dynamic, Track>(
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,
);
},
),
],
),
),
);
}
}

View File

@ -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<String> tracks, List<String> artists, List<String> 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<List<String>>(
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),
)
],
),
),
),
],
),
),
),
);
}
}

View File

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:spotify/spotify.dart';
class CustomSpotifyEndpoints { class CustomSpotifyEndpoints {
static const _baseUrl = 'https://api.spotify.com/v1'; static const _baseUrl = 'https://api.spotify.com/v1';
@ -80,4 +81,83 @@ class CustomSpotifyEndpoints {
); );
} }
} }
Future<List<String>> 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<String>.from(body["genres"] ?? []);
} else {
throw Exception(
'[CustomSpotifyEndpoints.listGenreSeeds]: Failed to get genre seeds'
'\nStatus code: ${res.statusCode}'
'\nBody: ${res.body}',
);
}
}
void _addList(
Map<String, String> parameters, String key, Iterable<String> paramList) {
if (paramList.isNotEmpty) {
parameters[key] = paramList.join(',');
}
}
void _addTunableTrackMap(
Map<String, String> parameters, Map<String, num>? tunableTrackMap) {
if (tunableTrackMap != null) {
parameters.addAll(tunableTrackMap.map<String, String>((k, v) =>
MapEntry(k, v is int ? v.toString() : v.toStringAsFixed(2))));
}
}
Future<List<Track>> getRecommendations({
Iterable<String>? seedArtists,
Iterable<String>? seedGenres,
Iterable<String>? seedTracks,
int limit = 20,
String? market,
Map<String, num>? max,
Map<String, num>? min,
Map<String, num>? 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 = <String, String>{'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<dynamic, Track>(
result["tracks"].map((track) => Track.fromJson(track)).toList(),
);
}
} }

View File

@ -1,9 +1,11 @@
import 'package:fl_query/fl_query.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:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_spotify_infinite_query.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'; import 'package:spotube/provider/user_preferences_provider.dart';
class CategoryQueries { class CategoryQueries {
@ -69,4 +71,25 @@ class CategoryQueries {
ref: ref, ref: ref,
); );
} }
Query<List<String>, dynamic> genreSeeds(WidgetRef ref) {
final customSpotify = ref.watch(customSpotifyEndpointProvider);
final query = useQuery<List<String>, dynamic>(
"genre-seeds",
customSpotify.listGenreSeeds,
);
useEffect(() {
return ref.listenManual(
customSpotifyEndpointProvider,
(previous, next) {
if (previous != next) {
query.refresh();
}
},
).close;
}, [query]);
return query;
}
} }

View File

@ -1,11 +1,53 @@
import 'dart:convert';
import 'package:catcher/catcher.dart'; import 'package:catcher/catcher.dart';
import 'package:fl_query/fl_query.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/map.dart'; import 'package:spotube/extensions/map.dart';
import 'package:spotube/extensions/track.dart'; import 'package:spotube/extensions/track.dart';
import 'package:spotube/hooks/use_spotify_infinite_query.dart'; import 'package:spotube/hooks/use_spotify_infinite_query.dart';
import 'package:spotube/hooks/use_spotify_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<String, num> 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 { class PlaylistQueries {
const PlaylistQueries(); const PlaylistQueries();
@ -94,4 +136,49 @@ class PlaylistQueries {
ref: ref, ref: ref,
); );
} }
Query<List<Track>, dynamic> generate(
WidgetRef ref, {
({List<String> tracks, List<String> artists, List<String> 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<List<Track>, 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;
}
} }

View File

@ -30,7 +30,7 @@ class CustomToastHandler extends ReportHandler {
), ),
), ),
dismissable: true, dismissable: true,
toastDuration: const Duration(seconds: 10), toastDuration: const Duration(seconds: 5),
borderRadius: 10, borderRadius: 10,
).show(context); ).show(context);
return true; return true;