feat: use providers in generate playlist

This commit is contained in:
Kingkor Roy Tirtho 2024-03-17 13:52:17 +06:00
parent b76e265f23
commit ac8121647b
8 changed files with 777 additions and 343 deletions

View File

@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/getting_started/getting_started.dart';
import 'package:spotube/pages/home/genres/genre_playlists.dart';
@ -96,8 +97,7 @@ final routerProvider = Provider((ref) {
path: "result",
pageBuilder: (context, state) => SpotubePage(
child: PlaylistGenerateResultPage(
state:
state.extra as PlaylistGenerateResultRouteState,
state: state.extra as GeneratePlaylistProviderInput,
),
),
),

View File

@ -3,9 +3,22 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'recommendation_seeds.freezed.dart';
part 'recommendation_seeds.g.dart';
@freezed
class GeneratePlaylistProviderInput with _$GeneratePlaylistProviderInput {
factory GeneratePlaylistProviderInput({
Iterable<String>? seedArtists,
Iterable<String>? seedGenres,
Iterable<String>? seedTracks,
required int limit,
RecommendationSeeds? max,
RecommendationSeeds? min,
RecommendationSeeds? target,
}) = _GeneratePlaylistProviderInput;
}
@freezed
class RecommendationSeeds with _$RecommendationSeeds {
factory RecommendationSeeds(
factory RecommendationSeeds({
num? acousticness,
num? danceability,
@JsonKey(name: "duration_ms") num? durationMs,
@ -20,7 +33,7 @@ class RecommendationSeeds with _$RecommendationSeeds {
num? tempo,
@JsonKey(name: "time_signature") num? timeSignature,
num? valence,
) = _RecommendationSeeds;
}) = _RecommendationSeeds;
factory RecommendationSeeds.fromJson(Map<String, dynamic> json) =>
_$RecommendationSeedsFromJson(json);

View File

@ -14,6 +14,316 @@ T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods');
/// @nodoc
mixin _$GeneratePlaylistProviderInput {
Iterable<String>? get seedArtists => throw _privateConstructorUsedError;
Iterable<String>? get seedGenres => throw _privateConstructorUsedError;
Iterable<String>? get seedTracks => throw _privateConstructorUsedError;
int get limit => throw _privateConstructorUsedError;
RecommendationSeeds? get max => throw _privateConstructorUsedError;
RecommendationSeeds? get min => throw _privateConstructorUsedError;
RecommendationSeeds? get target => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$GeneratePlaylistProviderInputCopyWith<GeneratePlaylistProviderInput>
get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $GeneratePlaylistProviderInputCopyWith<$Res> {
factory $GeneratePlaylistProviderInputCopyWith(
GeneratePlaylistProviderInput value,
$Res Function(GeneratePlaylistProviderInput) then) =
_$GeneratePlaylistProviderInputCopyWithImpl<$Res,
GeneratePlaylistProviderInput>;
@useResult
$Res call(
{Iterable<String>? seedArtists,
Iterable<String>? seedGenres,
Iterable<String>? seedTracks,
int limit,
RecommendationSeeds? max,
RecommendationSeeds? min,
RecommendationSeeds? target});
$RecommendationSeedsCopyWith<$Res>? get max;
$RecommendationSeedsCopyWith<$Res>? get min;
$RecommendationSeedsCopyWith<$Res>? get target;
}
/// @nodoc
class _$GeneratePlaylistProviderInputCopyWithImpl<$Res,
$Val extends GeneratePlaylistProviderInput>
implements $GeneratePlaylistProviderInputCopyWith<$Res> {
_$GeneratePlaylistProviderInputCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? seedArtists = freezed,
Object? seedGenres = freezed,
Object? seedTracks = freezed,
Object? limit = null,
Object? max = freezed,
Object? min = freezed,
Object? target = freezed,
}) {
return _then(_value.copyWith(
seedArtists: freezed == seedArtists
? _value.seedArtists
: seedArtists // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
seedGenres: freezed == seedGenres
? _value.seedGenres
: seedGenres // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
seedTracks: freezed == seedTracks
? _value.seedTracks
: seedTracks // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
limit: null == limit
? _value.limit
: limit // ignore: cast_nullable_to_non_nullable
as int,
max: freezed == max
? _value.max
: max // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
min: freezed == min
? _value.min
: min // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
target: freezed == target
? _value.target
: target // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
) as $Val);
}
@override
@pragma('vm:prefer-inline')
$RecommendationSeedsCopyWith<$Res>? get max {
if (_value.max == null) {
return null;
}
return $RecommendationSeedsCopyWith<$Res>(_value.max!, (value) {
return _then(_value.copyWith(max: value) as $Val);
});
}
@override
@pragma('vm:prefer-inline')
$RecommendationSeedsCopyWith<$Res>? get min {
if (_value.min == null) {
return null;
}
return $RecommendationSeedsCopyWith<$Res>(_value.min!, (value) {
return _then(_value.copyWith(min: value) as $Val);
});
}
@override
@pragma('vm:prefer-inline')
$RecommendationSeedsCopyWith<$Res>? get target {
if (_value.target == null) {
return null;
}
return $RecommendationSeedsCopyWith<$Res>(_value.target!, (value) {
return _then(_value.copyWith(target: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$GeneratePlaylistProviderInputImplCopyWith<$Res>
implements $GeneratePlaylistProviderInputCopyWith<$Res> {
factory _$$GeneratePlaylistProviderInputImplCopyWith(
_$GeneratePlaylistProviderInputImpl value,
$Res Function(_$GeneratePlaylistProviderInputImpl) then) =
__$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{Iterable<String>? seedArtists,
Iterable<String>? seedGenres,
Iterable<String>? seedTracks,
int limit,
RecommendationSeeds? max,
RecommendationSeeds? min,
RecommendationSeeds? target});
@override
$RecommendationSeedsCopyWith<$Res>? get max;
@override
$RecommendationSeedsCopyWith<$Res>? get min;
@override
$RecommendationSeedsCopyWith<$Res>? get target;
}
/// @nodoc
class __$$GeneratePlaylistProviderInputImplCopyWithImpl<$Res>
extends _$GeneratePlaylistProviderInputCopyWithImpl<$Res,
_$GeneratePlaylistProviderInputImpl>
implements _$$GeneratePlaylistProviderInputImplCopyWith<$Res> {
__$$GeneratePlaylistProviderInputImplCopyWithImpl(
_$GeneratePlaylistProviderInputImpl _value,
$Res Function(_$GeneratePlaylistProviderInputImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? seedArtists = freezed,
Object? seedGenres = freezed,
Object? seedTracks = freezed,
Object? limit = null,
Object? max = freezed,
Object? min = freezed,
Object? target = freezed,
}) {
return _then(_$GeneratePlaylistProviderInputImpl(
seedArtists: freezed == seedArtists
? _value.seedArtists
: seedArtists // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
seedGenres: freezed == seedGenres
? _value.seedGenres
: seedGenres // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
seedTracks: freezed == seedTracks
? _value.seedTracks
: seedTracks // ignore: cast_nullable_to_non_nullable
as Iterable<String>?,
limit: null == limit
? _value.limit
: limit // ignore: cast_nullable_to_non_nullable
as int,
max: freezed == max
? _value.max
: max // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
min: freezed == min
? _value.min
: min // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
target: freezed == target
? _value.target
: target // ignore: cast_nullable_to_non_nullable
as RecommendationSeeds?,
));
}
}
/// @nodoc
class _$GeneratePlaylistProviderInputImpl
implements _GeneratePlaylistProviderInput {
_$GeneratePlaylistProviderInputImpl(
{this.seedArtists,
this.seedGenres,
this.seedTracks,
required this.limit,
this.max,
this.min,
this.target});
@override
final Iterable<String>? seedArtists;
@override
final Iterable<String>? seedGenres;
@override
final Iterable<String>? seedTracks;
@override
final int limit;
@override
final RecommendationSeeds? max;
@override
final RecommendationSeeds? min;
@override
final RecommendationSeeds? target;
@override
String toString() {
return 'GeneratePlaylistProviderInput(seedArtists: $seedArtists, seedGenres: $seedGenres, seedTracks: $seedTracks, limit: $limit, max: $max, min: $min, target: $target)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$GeneratePlaylistProviderInputImpl &&
const DeepCollectionEquality()
.equals(other.seedArtists, seedArtists) &&
const DeepCollectionEquality()
.equals(other.seedGenres, seedGenres) &&
const DeepCollectionEquality()
.equals(other.seedTracks, seedTracks) &&
(identical(other.limit, limit) || other.limit == limit) &&
(identical(other.max, max) || other.max == max) &&
(identical(other.min, min) || other.min == min) &&
(identical(other.target, target) || other.target == target));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(seedArtists),
const DeepCollectionEquality().hash(seedGenres),
const DeepCollectionEquality().hash(seedTracks),
limit,
max,
min,
target);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$GeneratePlaylistProviderInputImplCopyWith<
_$GeneratePlaylistProviderInputImpl>
get copyWith => __$$GeneratePlaylistProviderInputImplCopyWithImpl<
_$GeneratePlaylistProviderInputImpl>(this, _$identity);
}
abstract class _GeneratePlaylistProviderInput
implements GeneratePlaylistProviderInput {
factory _GeneratePlaylistProviderInput(
{final Iterable<String>? seedArtists,
final Iterable<String>? seedGenres,
final Iterable<String>? seedTracks,
required final int limit,
final RecommendationSeeds? max,
final RecommendationSeeds? min,
final RecommendationSeeds? target}) = _$GeneratePlaylistProviderInputImpl;
@override
Iterable<String>? get seedArtists;
@override
Iterable<String>? get seedGenres;
@override
Iterable<String>? get seedTracks;
@override
int get limit;
@override
RecommendationSeeds? get max;
@override
RecommendationSeeds? get min;
@override
RecommendationSeeds? get target;
@override
@JsonKey(ignore: true)
_$$GeneratePlaylistProviderInputImplCopyWith<
_$GeneratePlaylistProviderInputImpl>
get copyWith => throw _privateConstructorUsedError;
}
RecommendationSeeds _$RecommendationSeedsFromJson(Map<String, dynamic> json) {
return _RecommendationSeeds.fromJson(json);
}
@ -207,59 +517,59 @@ class __$$RecommendationSeedsImplCopyWithImpl<$Res>
Object? valence = freezed,
}) {
return _then(_$RecommendationSeedsImpl(
freezed == acousticness
acousticness: freezed == acousticness
? _value.acousticness
: acousticness // ignore: cast_nullable_to_non_nullable
as num?,
freezed == danceability
danceability: freezed == danceability
? _value.danceability
: danceability // ignore: cast_nullable_to_non_nullable
as num?,
freezed == durationMs
durationMs: freezed == durationMs
? _value.durationMs
: durationMs // ignore: cast_nullable_to_non_nullable
as num?,
freezed == energy
energy: freezed == energy
? _value.energy
: energy // ignore: cast_nullable_to_non_nullable
as num?,
freezed == instrumentalness
instrumentalness: freezed == instrumentalness
? _value.instrumentalness
: instrumentalness // ignore: cast_nullable_to_non_nullable
as num?,
freezed == key
key: freezed == key
? _value.key
: key // ignore: cast_nullable_to_non_nullable
as num?,
freezed == liveness
liveness: freezed == liveness
? _value.liveness
: liveness // ignore: cast_nullable_to_non_nullable
as num?,
freezed == loudness
loudness: freezed == loudness
? _value.loudness
: loudness // ignore: cast_nullable_to_non_nullable
as num?,
freezed == mode
mode: freezed == mode
? _value.mode
: mode // ignore: cast_nullable_to_non_nullable
as num?,
freezed == popularity
popularity: freezed == popularity
? _value.popularity
: popularity // ignore: cast_nullable_to_non_nullable
as num?,
freezed == speechiness
speechiness: freezed == speechiness
? _value.speechiness
: speechiness // ignore: cast_nullable_to_non_nullable
as num?,
freezed == tempo
tempo: freezed == tempo
? _value.tempo
: tempo // ignore: cast_nullable_to_non_nullable
as num?,
freezed == timeSignature
timeSignature: freezed == timeSignature
? _value.timeSignature
: timeSignature // ignore: cast_nullable_to_non_nullable
as num?,
freezed == valence
valence: freezed == valence
? _value.valence
: valence // ignore: cast_nullable_to_non_nullable
as num?,
@ -271,7 +581,7 @@ class __$$RecommendationSeedsImplCopyWithImpl<$Res>
@JsonSerializable()
class _$RecommendationSeedsImpl implements _RecommendationSeeds {
_$RecommendationSeedsImpl(
this.acousticness,
{this.acousticness,
this.danceability,
@JsonKey(name: "duration_ms") this.durationMs,
this.energy,
@ -284,7 +594,7 @@ class _$RecommendationSeedsImpl implements _RecommendationSeeds {
this.speechiness,
this.tempo,
@JsonKey(name: "time_signature") this.timeSignature,
this.valence);
this.valence});
factory _$RecommendationSeedsImpl.fromJson(Map<String, dynamic> json) =>
_$$RecommendationSeedsImplFromJson(json);
@ -391,7 +701,7 @@ class _$RecommendationSeedsImpl implements _RecommendationSeeds {
abstract class _RecommendationSeeds implements RecommendationSeeds {
factory _RecommendationSeeds(
final num? acousticness,
{final num? acousticness,
final num? danceability,
@JsonKey(name: "duration_ms") final num? durationMs,
final num? energy,
@ -404,7 +714,7 @@ abstract class _RecommendationSeeds implements RecommendationSeeds {
final num? speechiness,
final num? tempo,
@JsonKey(name: "time_signature") final num? timeSignature,
final num? valence) = _$RecommendationSeedsImpl;
final num? valence}) = _$RecommendationSeedsImpl;
factory _RecommendationSeeds.fromJson(Map<String, dynamic> json) =
_$RecommendationSeedsImpl.fromJson;

View File

@ -9,20 +9,20 @@ part of 'recommendation_seeds.dart';
_$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson(
Map<String, dynamic> json) =>
_$RecommendationSeedsImpl(
json['acousticness'] as num?,
json['danceability'] as num?,
json['duration_ms'] as num?,
json['energy'] as num?,
json['instrumentalness'] as num?,
json['key'] as num?,
json['liveness'] as num?,
json['loudness'] as num?,
json['mode'] as num?,
json['popularity'] as num?,
json['speechiness'] as num?,
json['tempo'] as num?,
json['time_signature'] as num?,
json['valence'] as num?,
acousticness: json['acousticness'] as num?,
danceability: json['danceability'] as num?,
durationMs: json['duration_ms'] as num?,
energy: json['energy'] as num?,
instrumentalness: json['instrumentalness'] as num?,
key: json['key'] as num?,
liveness: json['liveness'] as num?,
loudness: json['loudness'] as num?,
mode: json['mode'] as num?,
popularity: json['popularity'] as num?,
speechiness: json['speechiness'] as num?,
tempo: json['tempo'] as num?,
timeSignature: json['time_signature'] as num?,
valence: json['valence'] as num?,
);
Map<String, dynamic> _$$RecommendationSeedsImplToJson(

View File

@ -15,11 +15,10 @@ import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart';
import 'package:spotube/models/spotify/recommendation_seeds.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/queries/playlist.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);
@ -35,7 +34,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
final textTheme = theme.textTheme;
final preferences = ref.watch(userPreferencesProvider);
final genresCollection = useQueries.category.genreSeeds(ref);
final genresCollection = ref.watch(categoryGenresProvider);
final limit = useValueNotifier<int>(10);
final market = useValueNotifier<Market>(preferences.recommendationMarket);
@ -51,22 +50,9 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
5 - genres.value.length - artists.value.length - tracks.value.length;
// Dial (int 0-1) attributes
final acousticness = useState<RecommendationAttribute>(zeroValues);
final danceability = useState<RecommendationAttribute>(zeroValues);
final energy = useState<RecommendationAttribute>(zeroValues);
final instrumentalness = useState<RecommendationAttribute>(zeroValues);
final key = useState<RecommendationAttribute>(zeroValues);
final liveness = useState<RecommendationAttribute>(zeroValues);
final loudness = useState<RecommendationAttribute>(zeroValues);
final popularity = useState<RecommendationAttribute>(zeroValues);
final speechiness = useState<RecommendationAttribute>(zeroValues);
final valence = useState<RecommendationAttribute>(zeroValues);
// Field editable attributes
final tempo = useState<RecommendationAttribute>(zeroValues);
final durationMs = useState<RecommendationAttribute>(zeroValues);
final mode = useState<RecommendationAttribute>(zeroValues);
final timeSignature = useState<RecommendationAttribute>(zeroValues);
final min = useState<RecommendationSeeds>(RecommendationSeeds());
final max = useState<RecommendationSeeds>(RecommendationSeeds());
final target = useState<RecommendationSeeds>(RecommendationSeeds());
final artistAutoComplete = SeedsMultiAutocomplete<Artist>(
seeds: artists,
@ -204,7 +190,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
);
final genreSelector = MultiSelectField<String>(
options: genresCollection.data ?? [],
options: genresCollection.value ?? [],
selectedOptions: genres.value,
getValueForOption: (option) => option,
onSelected: (value) {
@ -356,88 +342,213 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
const SizedBox(height: 16),
RecommendationAttributeDials(
title: Text(context.l10n.acousticness),
values: acousticness.value,
values: (
target: target.value.acousticness?.toDouble() ?? 0,
min: min.value.acousticness?.toDouble() ?? 0,
max: max.value.acousticness?.toDouble() ?? 0,
),
onChanged: (value) {
acousticness.value = value;
target.value = target.value.copyWith(
acousticness: value.target,
);
min.value = min.value.copyWith(
acousticness: value.min,
);
max.value = max.value.copyWith(
acousticness: value.max,
);
},
),
RecommendationAttributeDials(
title: Text(context.l10n.danceability),
values: danceability.value,
values: (
target: target.value.danceability?.toDouble() ?? 0,
min: min.value.danceability?.toDouble() ?? 0,
max: max.value.danceability?.toDouble() ?? 0,
),
onChanged: (value) {
danceability.value = value;
target.value = target.value.copyWith(
danceability: value.target,
);
min.value = min.value.copyWith(
danceability: value.min,
);
max.value = max.value.copyWith(
danceability: value.max,
);
},
),
RecommendationAttributeDials(
title: Text(context.l10n.energy),
values: energy.value,
values: (
target: target.value.energy?.toDouble() ?? 0,
min: min.value.energy?.toDouble() ?? 0,
max: max.value.energy?.toDouble() ?? 0,
),
onChanged: (value) {
energy.value = value;
target.value = target.value.copyWith(
energy: value.target,
);
min.value = min.value.copyWith(
energy: value.min,
);
max.value = max.value.copyWith(
energy: value.max,
);
},
),
RecommendationAttributeDials(
title: Text(context.l10n.instrumentalness),
values: instrumentalness.value,
values: (
target:
target.value.instrumentalness?.toDouble() ?? 0,
min: min.value.instrumentalness?.toDouble() ?? 0,
max: max.value.instrumentalness?.toDouble() ?? 0,
),
onChanged: (value) {
instrumentalness.value = value;
target.value = target.value.copyWith(
instrumentalness: value.target,
);
min.value = min.value.copyWith(
instrumentalness: value.min,
);
max.value = max.value.copyWith(
instrumentalness: value.max,
);
},
),
RecommendationAttributeDials(
title: Text(context.l10n.liveness),
values: liveness.value,
values: (
target: target.value.liveness?.toDouble() ?? 0,
min: min.value.liveness?.toDouble() ?? 0,
max: max.value.liveness?.toDouble() ?? 0,
),
onChanged: (value) {
liveness.value = value;
target.value = target.value.copyWith(
liveness: value.target,
);
min.value = min.value.copyWith(
liveness: value.min,
);
max.value = max.value.copyWith(
liveness: value.max,
);
},
),
RecommendationAttributeDials(
title: Text(context.l10n.loudness),
values: loudness.value,
values: (
target: target.value.loudness?.toDouble() ?? 0,
min: min.value.loudness?.toDouble() ?? 0,
max: max.value.loudness?.toDouble() ?? 0,
),
onChanged: (value) {
loudness.value = value;
target.value = target.value.copyWith(
loudness: value.target,
);
min.value = min.value.copyWith(
loudness: value.min,
);
max.value = max.value.copyWith(
loudness: value.max,
);
},
),
RecommendationAttributeDials(
title: Text(context.l10n.speechiness),
values: speechiness.value,
values: (
target: target.value.speechiness?.toDouble() ?? 0,
min: min.value.speechiness?.toDouble() ?? 0,
max: max.value.speechiness?.toDouble() ?? 0,
),
onChanged: (value) {
speechiness.value = value;
target.value = target.value.copyWith(
speechiness: value.target,
);
min.value = min.value.copyWith(
speechiness: value.min,
);
max.value = max.value.copyWith(
speechiness: value.max,
);
},
),
RecommendationAttributeDials(
title: Text(context.l10n.valence),
values: valence.value,
values: (
target: target.value.valence?.toDouble() ?? 0,
min: min.value.valence?.toDouble() ?? 0,
max: max.value.valence?.toDouble() ?? 0,
),
onChanged: (value) {
valence.value = value;
target.value = target.value.copyWith(
valence: value.target,
);
min.value = min.value.copyWith(
valence: value.min,
);
max.value = max.value.copyWith(
valence: value.max,
);
},
),
RecommendationAttributeDials(
title: Text(context.l10n.popularity),
values: popularity.value,
base: 100,
values: (
target: target.value.popularity?.toDouble() ?? 0,
min: min.value.popularity?.toDouble() ?? 0,
max: max.value.popularity?.toDouble() ?? 0,
),
onChanged: (value) {
popularity.value = value;
target.value = target.value.copyWith(
popularity: value.target,
);
min.value = min.value.copyWith(
popularity: value.min,
);
max.value = max.value.copyWith(
popularity: value.max,
);
},
),
RecommendationAttributeDials(
title: Text(context.l10n.key),
values: key.value,
base: 11,
values: (
target: target.value.key?.toDouble() ?? 0,
min: min.value.key?.toDouble() ?? 0,
max: max.value.key?.toDouble() ?? 0,
),
onChanged: (value) {
key.value = value;
target.value = target.value.copyWith(
key: value.target,
);
min.value = min.value.copyWith(
key: value.min,
);
max.value = max.value.copyWith(
key: value.max,
);
},
),
RecommendationAttributeFields(
title: Text(context.l10n.duration),
values: (
max: durationMs.value.max / 1000,
target: durationMs.value.target / 1000,
min: durationMs.value.min / 1000,
max: (max.value.durationMs ?? 0) / 1000,
target: (target.value.durationMs ?? 0) / 1000,
min: (min.value.durationMs ?? 0) / 1000,
),
onChanged: (value) {
durationMs.value = (
max: value.max * 1000,
target: value.target * 1000,
min: value.min * 1000,
target.value = target.value.copyWith(
durationMs: (value.target * 1000).toInt(),
);
min.value = min.value.copyWith(
durationMs: (value.min * 1000).toInt(),
);
max.value = max.value.copyWith(
durationMs: (value.max * 1000).toInt(),
);
},
presets: {
@ -452,23 +563,59 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
),
RecommendationAttributeFields(
title: Text(context.l10n.tempo),
values: tempo.value,
values: (
max: max.value.tempo?.toDouble() ?? 0,
target: target.value.tempo?.toDouble() ?? 0,
min: min.value.tempo?.toDouble() ?? 0,
),
onChanged: (value) {
tempo.value = value;
target.value = target.value.copyWith(
tempo: value.target,
);
min.value = min.value.copyWith(
tempo: value.min,
);
max.value = max.value.copyWith(
tempo: value.max,
);
},
),
RecommendationAttributeFields(
title: Text(context.l10n.mode),
values: mode.value,
values: (
max: max.value.mode?.toDouble() ?? 0,
target: target.value.mode?.toDouble() ?? 0,
min: min.value.mode?.toDouble() ?? 0,
),
onChanged: (value) {
mode.value = value;
target.value = target.value.copyWith(
mode: value.target,
);
min.value = min.value.copyWith(
mode: value.min,
);
max.value = max.value.copyWith(
mode: value.max,
);
},
),
RecommendationAttributeFields(
title: Text(context.l10n.time_signature),
values: timeSignature.value,
values: (
max: max.value.timeSignature?.toDouble() ?? 0,
target: target.value.timeSignature?.toDouble() ?? 0,
min: min.value.timeSignature?.toDouble() ?? 0,
),
onChanged: (value) {
timeSignature.value = value;
target.value = target.value.copyWith(
timeSignature: value.target,
);
min.value = min.value.copyWith(
timeSignature: value.min,
);
max.value = max.value.copyWith(
timeSignature: value.max,
);
},
),
const SizedBox(height: 20),
@ -480,35 +627,18 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
genres.value.isEmpty
? null
: () {
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,
final routeState =
GeneratePlaylistProviderInput(
seedArtists: artists.value
.map((a) => a.id!)
.toList(),
seedTracks:
tracks.value.map((t) => t.id!).toList(),
seedGenres: genres.value,
limit: limit.value,
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,
),
max: max.value,
min: min.value,
target: target.value,
);
GoRouter.of(context).push(
"/library/generate/result",

View File

@ -1,4 +1,3 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
@ -10,19 +9,12 @@ import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/spotify/recommendation_seeds.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? parameters,
int limit,
Market? market,
});
import 'package:spotube/provider/spotify/spotify.dart';
class PlaylistGenerateResultPage extends HookConsumerWidget {
final PlaylistGenerateResultRouteState state;
final GeneratePlaylistProviderInput state;
const PlaylistGenerateResultPage({
super.key,
@ -34,225 +26,207 @@ class PlaylistGenerateResultPage extends HookConsumerWidget {
final router = GoRouter.of(context);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final (:seeds, :parameters, :limit, :market) = state;
final queryClient = useQueryClient();
final generatedPlaylist = useQueries.playlist.generate(
ref,
seeds: seeds,
parameters: parameters,
limit: limit,
market: market,
);
final generatedPlaylist = ref.watch(generatePlaylistProvider(state));
final selectedTracks = useState<List<String>>(
generatedPlaylist.data?.map((e) => e.id!).toList() ?? [],
generatedPlaylist.value?.map((e) => e.id!).toList() ?? [],
);
useEffect(() {
if (generatedPlaylist.data != null) {
if (generatedPlaylist.value != null) {
selectedTracks.value =
generatedPlaylist.data!.map((e) => e.id!).toList();
generatedPlaylist.value!.map((e) => e.id!).toList();
}
return null;
}, [generatedPlaylist.data]);
}, [generatedPlaylist.value]);
final isAllTrackSelected =
selectedTracks.value.length == (generatedPlaylist.data?.length ?? 0);
selectedTracks.value.length == (generatedPlaylist.value?.length ?? 0);
return WillPopScope(
onWillPop: () async {
queryClient.cache.removeQuery(generatedPlaylist);
return true;
},
child: Scaffold(
appBar: const PageWindowTitleBar(leading: BackButton()),
body: generatedPlaylist.isLoading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const CircularProgressIndicator(),
Text(context.l10n.generating_playlist),
],
),
)
: Padding(
padding: const EdgeInsets.all(8.0),
child: ListView(
children: [
GridView(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
mainAxisExtent: 32,
return Scaffold(
appBar: const PageWindowTitleBar(leading: BackButton()),
body: generatedPlaylist.isLoading
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const CircularProgressIndicator(),
Text(context.l10n.generating_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.value!.where(
(e) => selectedTracks.value.contains(e.id!),
),
autoPlay: true,
);
},
),
shrinkWrap: 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.value!.where(
(e) => selectedTracks.value.contains(e.id!),
),
);
if (context.mounted) {
scaffoldMessenger.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
: () async {
final playlist = await showDialog<Playlist>(
context: context,
builder: (context) => PlaylistCreateDialog(
trackIds: selectedTracks.value,
),
);
if (playlist != null) {
router.go(
'/playlist/${playlist.id}',
extra: playlist,
);
}
},
),
FilledButton.tonalIcon(
icon: const Icon(SpotubeIcons.playlistAdd),
label: Text(context.l10n.add_to_playlist),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
final hasAdded = await showDialog<bool>(
context: context,
builder: (context) => PlaylistAddTrackDialog(
openFromPlaylist: null,
tracks: selectedTracks.value
.map(
(e) => generatedPlaylist.value!
.firstWhere(
(element) => element.id == e,
),
)
.toList(),
),
);
if (context.mounted && hasAdded == true) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.add_count_to_playlist(
selectedTracks.value.length,
),
),
),
);
}
},
)
],
),
const SizedBox(height: 16),
if (generatedPlaylist.value != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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,
);
},
Text(
context.l10n.selected_count_tracks(
selectedTracks.value.length,
),
),
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.showSnackBar(
SnackBar(
content: Text(
context.l10n.add_count_to_queue(
selectedTracks.value.length,
),
),
),
);
}
},
ElevatedButton.icon(
onPressed: () {
if (isAllTrackSelected) {
selectedTracks.value = [];
} else {
selectedTracks.value = generatedPlaylist.value
?.map((e) => e.id!)
.toList() ??
[];
}
},
icon: const Icon(SpotubeIcons.selectionCheck),
label: Text(
isAllTrackSelected
? context.l10n.deselect_all
: context.l10n.select_all,
),
),
FilledButton.tonalIcon(
icon: const Icon(SpotubeIcons.addFilled),
label: Text(context.l10n.create_a_playlist),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
final playlist = await showDialog<Playlist>(
context: context,
builder: (context) => PlaylistCreateDialog(
trackIds: selectedTracks.value,
),
);
if (playlist != null) {
router.go(
'/playlist/${playlist.id}',
extra: playlist,
);
}
},
),
FilledButton.tonalIcon(
icon: const Icon(SpotubeIcons.playlistAdd),
label: Text(context.l10n.add_to_playlist),
onPressed: selectedTracks.value.isEmpty
? null
: () async {
final hasAdded = await showDialog<bool>(
context: context,
builder: (context) =>
PlaylistAddTrackDialog(
openFromPlaylist: null,
tracks: selectedTracks.value
.map(
(e) => generatedPlaylist.data!
.firstWhere(
(element) => element.id == e,
),
)
.toList(),
),
);
if (context.mounted && hasAdded == true) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.add_count_to_playlist(
selectedTracks.value.length,
),
),
),
);
}
},
)
],
),
const SizedBox(height: 16),
if (generatedPlaylist.data != null)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
const SizedBox(height: 8),
Card(
margin: const EdgeInsets.all(0),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.selected_count_tracks(
selectedTracks.value.length,
),
),
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
? context.l10n.deselect_all
: context.l10n.select_all,
),
),
for (final track in generatedPlaylist.value ?? [])
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),
)
],
),
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,34 +1,40 @@
part of '../spotify.dart';
typedef GeneratePlaylistProviderInput = ({
Iterable<String>? seedArtists,
Iterable<String>? seedGenres,
Iterable<String>? seedTracks,
int limit,
RecommendationSeeds? max,
RecommendationSeeds? min,
RecommendationSeeds? target,
});
final generatePlaylistProvider =
FutureProvider.family<List<TrackSimple>, GeneratePlaylistProviderInput>(
final generatePlaylistProvider = FutureProvider.autoDispose
.family<List<Track>, GeneratePlaylistProviderInput>(
(ref, input) async {
final spotify = ref.watch(spotifyProvider);
final market = ref.watch(
userPreferencesProvider.select((s) => s.recommendationMarket),
);
final recommendation = await spotify.recommendations.get(
final recommendation = await spotify.recommendations
.get(
limit: input.limit,
seedArtists: input.seedArtists?.toList(),
seedGenres: input.seedGenres?.toList(),
seedTracks: input.seedTracks?.toList(),
market: market,
max: input.max?.toJson().cast<String, num>(),
min: input.min?.toJson().cast<String, num>(),
target: input.target?.toJson().cast<String, num>(),
);
max: (input.max?.toJson()?..removeWhere((key, value) => value == null))
?.cast<String, num>(),
min: (input.min?.toJson()?..removeWhere((key, value) => value == null))
?.cast<String, num>(),
target: (input.target?.toJson()
?..removeWhere((key, value) => value == null))
?.cast<String, num>(),
)
.catchError((e, stackTrace) {
Catcher2.reportCheckedError(e, stackTrace);
return Recommendations();
});
return recommendation.tracks?.toList() ?? [];
if (recommendation.tracks?.isEmpty ?? true) {
return [];
}
final tracks = await spotify.tracks
.list(recommendation.tracks!.map((e) => e.id!).toList());
return tracks.toList();
},
);

View File

@ -3,6 +3,7 @@ library spotify;
import 'dart:async';
import 'dart:convert';
import 'package:catcher_2/catcher_2.dart';
import 'package:collection/collection.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:intl/intl.dart';