mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
feat: custom playlist generator
This commit is contained in:
parent
bed0d3bd70
commit
f4b0d134ca
@ -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<NavigatorState>();
|
||||
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",
|
||||
|
@ -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;
|
||||
}
|
||||
|
271
lib/components/library/playlist_generate/multi_select_field.dart
Normal file
271
lib/components/library/playlist_generate/multi_select_field.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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 ?? "",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<double>(
|
||||
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))
|
||||
|
@ -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);
|
||||
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);
|
||||
|
||||
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);
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
|
25
lib/extensions/constrains.dart
Normal file
25
lib/extensions/constrains.dart
Normal 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;
|
||||
}
|
@ -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"
|
||||
}
|
@ -119,7 +119,6 @@ Future<void> main(List<String> rawArgs) async {
|
||||
enableApplicationParameters: false,
|
||||
),
|
||||
FileHandler(await getLogsPath(), printLogs: false),
|
||||
CustomToastHandler(),
|
||||
],
|
||||
),
|
||||
releaseConfig: CatcherOptions(
|
||||
|
296
lib/pages/library/playlist_generate/playlist_generate.dart
Normal file
296
lib/pages/library/playlist_generate/playlist_generate.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
@ -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<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 {
|
||||
const PlaylistQueries();
|
||||
@ -94,4 +136,49 @@ class PlaylistQueries {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user