diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 43d0cf2e..8428aaf3 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -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, ), ), ), diff --git a/lib/models/spotify/recommendation_seeds.dart b/lib/models/spotify/recommendation_seeds.dart index 0052a71d..0d874ad6 100644 --- a/lib/models/spotify/recommendation_seeds.dart +++ b/lib/models/spotify/recommendation_seeds.dart @@ -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? seedArtists, + Iterable? seedGenres, + Iterable? 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 json) => _$RecommendationSeedsFromJson(json); diff --git a/lib/models/spotify/recommendation_seeds.freezed.dart b/lib/models/spotify/recommendation_seeds.freezed.dart index 3ff1a499..4cfcce12 100644 --- a/lib/models/spotify/recommendation_seeds.freezed.dart +++ b/lib/models/spotify/recommendation_seeds.freezed.dart @@ -14,6 +14,316 @@ T _$identity(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? get seedArtists => throw _privateConstructorUsedError; + Iterable? get seedGenres => throw _privateConstructorUsedError; + Iterable? 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 + get copyWith => throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $GeneratePlaylistProviderInputCopyWith<$Res> { + factory $GeneratePlaylistProviderInputCopyWith( + GeneratePlaylistProviderInput value, + $Res Function(GeneratePlaylistProviderInput) then) = + _$GeneratePlaylistProviderInputCopyWithImpl<$Res, + GeneratePlaylistProviderInput>; + @useResult + $Res call( + {Iterable? seedArtists, + Iterable? seedGenres, + Iterable? 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?, + seedGenres: freezed == seedGenres + ? _value.seedGenres + : seedGenres // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedTracks: freezed == seedTracks + ? _value.seedTracks + : seedTracks // ignore: cast_nullable_to_non_nullable + as Iterable?, + 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? seedArtists, + Iterable? seedGenres, + Iterable? 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?, + seedGenres: freezed == seedGenres + ? _value.seedGenres + : seedGenres // ignore: cast_nullable_to_non_nullable + as Iterable?, + seedTracks: freezed == seedTracks + ? _value.seedTracks + : seedTracks // ignore: cast_nullable_to_non_nullable + as Iterable?, + 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? seedArtists; + @override + final Iterable? seedGenres; + @override + final Iterable? 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? seedArtists, + final Iterable? seedGenres, + final Iterable? seedTracks, + required final int limit, + final RecommendationSeeds? max, + final RecommendationSeeds? min, + final RecommendationSeeds? target}) = _$GeneratePlaylistProviderInputImpl; + + @override + Iterable? get seedArtists; + @override + Iterable? get seedGenres; + @override + Iterable? 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 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 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 json) = _$RecommendationSeedsImpl.fromJson; diff --git a/lib/models/spotify/recommendation_seeds.g.dart b/lib/models/spotify/recommendation_seeds.g.dart index 5ab3e4d8..bdfa3a07 100644 --- a/lib/models/spotify/recommendation_seeds.g.dart +++ b/lib/models/spotify/recommendation_seeds.g.dart @@ -9,20 +9,20 @@ part of 'recommendation_seeds.dart'; _$RecommendationSeedsImpl _$$RecommendationSeedsImplFromJson( Map 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 _$$RecommendationSeedsImplToJson( diff --git a/lib/pages/library/playlist_generate/playlist_generate.dart b/lib/pages/library/playlist_generate/playlist_generate.dart index e3685491..642ceb6c 100644 --- a/lib/pages/library/playlist_generate/playlist_generate.dart +++ b/lib/pages/library/playlist_generate/playlist_generate.dart @@ -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(10); final market = useValueNotifier(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(zeroValues); - final danceability = useState(zeroValues); - final energy = useState(zeroValues); - final instrumentalness = useState(zeroValues); - final key = useState(zeroValues); - final liveness = useState(zeroValues); - final loudness = useState(zeroValues); - final popularity = useState(zeroValues); - final speechiness = useState(zeroValues); - final valence = useState(zeroValues); - - // Field editable attributes - final tempo = useState(zeroValues); - final durationMs = useState(zeroValues); - final mode = useState(zeroValues); - final timeSignature = useState(zeroValues); + final min = useState(RecommendationSeeds()); + final max = useState(RecommendationSeeds()); + final target = useState(RecommendationSeeds()); final artistAutoComplete = SeedsMultiAutocomplete( seeds: artists, @@ -204,7 +190,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget { ); final genreSelector = MultiSelectField( - 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", diff --git a/lib/pages/library/playlist_generate/playlist_generate_result.dart b/lib/pages/library/playlist_generate/playlist_generate_result.dart index c4e5b37b..7bbdb783 100644 --- a/lib/pages/library/playlist_generate/playlist_generate_result.dart +++ b/lib/pages/library/playlist_generate/playlist_generate_result.dart @@ -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 tracks, List artists, List 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>( - 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( + 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( + 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( - 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( - 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), - ) - ], - ), - ), ), - ], - ), + ), + ], ), - ), + ), ); } } diff --git a/lib/provider/spotify/playlist/generate.dart b/lib/provider/spotify/playlist/generate.dart index f2bbcd65..15447b54 100644 --- a/lib/provider/spotify/playlist/generate.dart +++ b/lib/provider/spotify/playlist/generate.dart @@ -1,34 +1,40 @@ part of '../spotify.dart'; -typedef GeneratePlaylistProviderInput = ({ - Iterable? seedArtists, - Iterable? seedGenres, - Iterable? seedTracks, - int limit, - RecommendationSeeds? max, - RecommendationSeeds? min, - RecommendationSeeds? target, -}); - -final generatePlaylistProvider = - FutureProvider.family, GeneratePlaylistProviderInput>( +final generatePlaylistProvider = FutureProvider.autoDispose + .family, 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(), - min: input.min?.toJson().cast(), - target: input.target?.toJson().cast(), - ); + max: (input.max?.toJson()?..removeWhere((key, value) => value == null)) + ?.cast(), + min: (input.min?.toJson()?..removeWhere((key, value) => value == null)) + ?.cast(), + target: (input.target?.toJson() + ?..removeWhere((key, value) => value == null)) + ?.cast(), + ) + .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(); }, ); diff --git a/lib/provider/spotify/spotify.dart b/lib/provider/spotify/spotify.dart index fdea3915..6b334185 100644 --- a/lib/provider/spotify/spotify.dart +++ b/lib/provider/spotify/spotify.dart @@ -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';