From d2e0dc1ac9f4abd603cf5e883587802f439206dc Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 19 Jul 2025 11:56:51 +0600 Subject: [PATCH] chore: re-enable endless playback --- lib/components/track_tile/track_options.dart | 111 +++++----- .../configurators/use_endless_playback.dart | 119 +++++------ lib/models/metadata/metadata.freezed.dart | 192 ++++++++++++++++++ lib/models/metadata/metadata.g.dart | 15 ++ lib/models/metadata/plugin.dart | 12 ++ lib/services/metadata/endpoints/artist.dart | 22 ++ lib/services/metadata/endpoints/track.dart | 15 ++ lib/services/metadata/endpoints/updater.dart | 26 +++ 8 files changed, 375 insertions(+), 137 deletions(-) create mode 100644 lib/services/metadata/endpoints/updater.dart diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index 09f361c2..f4a295f7 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -29,7 +29,6 @@ import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/metadata_plugin/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/library/playlists.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; -import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart'; import 'package:spotube/provider/metadata_plugin/core/user.dart'; import 'package:spotube/services/metadata/endpoints/error.dart'; @@ -107,71 +106,53 @@ class TrackOptions extends HookConsumerWidget { ); } - // void actionStartRadio( - // BuildContext context, - // WidgetRef ref, - // SpotubeTrackObject track, - // ) async { - // final playback = ref.read(audioPlayerProvider.notifier); - // final playlist = ref.read(audioPlayerProvider); - // final query = "${track.name} Radio"; - // final metadataPlugin = await ref.read(metadataPluginProvider.future); + void actionStartRadio( + BuildContext context, + WidgetRef ref, + SpotubeTrackObject track, + ) async { + final playback = ref.read(audioPlayerProvider.notifier); + final playlist = ref.read(audioPlayerProvider); + final metadataPlugin = await ref.read(metadataPluginProvider.future); - // if (metadataPlugin == null) { - // throw MetadataPluginException.noDefaultPlugin( - // "No default metadata plugin set", - // ); - // } + if (metadataPlugin == null) { + throw MetadataPluginException.noDefaultPlugin( + "No default metadata plugin set", + ); + } - // final pages = await metadataPlugin.search.playlists(query); + final tracks = await metadataPlugin.track.radio(track.id); - // final artists = track.artists.map((e) => e.name); + bool replaceQueue = false; - // final radio = pages.items.firstWhere( - // (e) { - // final validPlaylists = artists.where((a) => e.description.contains(a)); - // return e.name.contains(track.name) && - // e.name.contains("Radio") && - // (validPlaylists.length >= 2 || - // validPlaylists.length == artists.length); - // }, - // orElse: () => pages.items.first, - // ); + if (context.mounted && playlist.tracks.isNotEmpty) { + replaceQueue = await showPromptDialog( + context: context, + title: context.l10n.how_to_start_radio, + message: context.l10n.replace_queue_question, + okText: context.l10n.replace, + cancelText: context.l10n.add_to_queue, + ); + } - // bool replaceQueue = false; + if (replaceQueue || playlist.tracks.isEmpty) { + await playback.stop(); + await playback.load([track], autoPlay: true); - // if (context.mounted && playlist.tracks.isNotEmpty) { - // replaceQueue = await showPromptDialog( - // context: context, - // title: context.l10n.how_to_start_radio, - // message: context.l10n.replace_queue_question, - // okText: context.l10n.replace, - // cancelText: context.l10n.add_to_queue, - // ); - // } + // we don't have to add those tracks as useEndlessPlayback will do it for us + return; + } else { + await playback.addTrack(track); + } - // if (replaceQueue || playlist.tracks.isEmpty) { - // await playback.stop(); - // await playback.load([track], autoPlay: true); - - // // we don't have to add those tracks as useEndlessPlayback will do it for us - // return; - // } else { - // await playback.addTrack(track); - // } - // await ref.read(metadataPluginPlaylistTracksProvider(radio.id).future); - // final tracks = await ref - // .read(metadataPluginPlaylistTracksProvider(radio.id).notifier) - // .fetchAll(); - - // await playback.addTracks( - // tracks.toList() - // ..removeWhere((e) { - // final isDuplicate = playlist.tracks.any((t) => t.id == e.id); - // return e.id == track.id || isDuplicate; - // }), - // ); - // } + await playback.addTracks( + tracks.toList() + ..removeWhere((e) { + final isDuplicate = playlist.tracks.any((t) => t.id == e.id); + return e.id == track.id || isDuplicate; + }), + ); + } @override Widget build(BuildContext context, ref) { @@ -338,7 +319,7 @@ class TrackOptions extends HookConsumerWidget { await downloadManager.addToQueue(track as SpotubeFullTrackObject); break; case TrackOptionValue.startRadio: - // actionStartRadio(context, ref, track); + actionStartRadio(context, ref, track); break; } }, @@ -430,11 +411,11 @@ class TrackOptions extends HookConsumerWidget { ), ), if (authenticated.asData?.value == true && !isLocalTrack) ...[ - // AdaptiveMenuButton( - // value: TrackOptionValue.startRadio, - // leading: const Icon(SpotubeIcons.radio), - // child: Text(context.l10n.start_a_radio), - // ), + AdaptiveMenuButton( + value: TrackOptionValue.startRadio, + leading: const Icon(SpotubeIcons.radio), + child: Text(context.l10n.start_a_radio), + ), AdaptiveMenuButton( value: TrackOptionValue.addToPlaylist, leading: const Icon(SpotubeIcons.playlistAdd), diff --git a/lib/hooks/configurators/use_endless_playback.dart b/lib/hooks/configurators/use_endless_playback.dart index f010a0aa..9e8c191e 100644 --- a/lib/hooks/configurators/use_endless_playback.dart +++ b/lib/hooks/configurators/use_endless_playback.dart @@ -1,4 +1,4 @@ -import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart'; +import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,85 +6,60 @@ import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; -// TODO: Implement endless playback functionality void useEndlessPlayback(WidgetRef ref) { - // final auth = ref.watch(authenticationProvider); - // final playback = ref.watch(audioPlayerProvider.notifier); - // final audioPlayerState = ref.watch(audioPlayerProvider); - // final spotify = ref.watch(spotifyProvider); - // final endlessPlayback = - // ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback)); + final playback = ref.watch(audioPlayerProvider.notifier); + final audioPlayerState = ref.watch(audioPlayerProvider); + final endlessPlayback = + ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback)); + final metadataPlugin = ref.watch(metadataPluginProvider.future); - // useEffect( - // () { - // if (!endlessPlayback || auth.asData?.value == null) return null; + useEffect( + () { + if (!endlessPlayback) return null; - // void listener(int index) async { - // try { - // final playlist = ref.read(audioPlayerProvider); - // if (index != playlist.tracks.length - 1) return; + void listener(int index) async { + try { + final playlist = ref.read(audioPlayerProvider); + if (index != playlist.tracks.length - 1) return; - // final track = playlist.tracks.last; + final track = playlist.tracks.last; - // final query = "${track.name} Radio"; - // final pages = await spotify.invoke((api) => - // api.search.get(query, types: [SearchType.playlist]).first()); + final tracks = await (await metadataPlugin)?.track.radio(track.id); - // final radios = pages - // .expand((e) => e.items?.toList() ?? []) - // .toList() - // .cast(); + if (tracks == null || tracks.isEmpty) return; - // final artists = track.artists.map((e) => e.name); + await playback.addTracks( + tracks.toList() + ..removeWhere((e) { + final playlist = ref.read(audioPlayerProvider); + final isDuplicate = playlist.tracks.any((t) => t.id == e.id); + return e.id == track.id || isDuplicate; + }), + ); + } catch (e, stack) { + AppLogger.reportError(e, stack); + } + } - // final radio = radios.firstWhere( - // (e) { - // final validPlaylists = - // artists.where((a) => e.description!.contains(a)); - // return e.name == "${track.name} Radio" && - // (validPlaylists.length >= 2 || - // validPlaylists.length == artists.length) && - // e.owner?.displayName != "Spotify"; - // }, - // orElse: () => radios.first, - // ); + // Sometimes user can change settings for which the currentIndexChanged + // might not be called. So we need to check if the current track is the + // last track and if it is then we need to call the listener manually. + if (audioPlayerState.currentIndex == audioPlayerState.tracks.length - 1 && + audioPlayer.isPlaying) { + listener(audioPlayerState.currentIndex); + } - // final tracks = - // ref.read(metadataPluginPlaylistTracksProvider(radio.id!)); + final subscription = + audioPlayer.currentIndexChangedStream.listen(listener); - // await playback.addTracks( - // tracks.toList() - // ..removeWhere((e) { - // final playlist = ref.read(audioPlayerProvider); - // final isDuplicate = playlist.tracks.any((t) => t.id == e.id); - // return e.id == track.id || isDuplicate; - // }), - // ); - // } catch (e, stack) { - // AppLogger.reportError(e, stack); - // } - // } - - // // Sometimes user can change settings for which the currentIndexChanged - // // might not be called. So we need to check if the current track is the - // // last track and if it is then we need to call the listener manually. - // if (audioPlayerState.currentIndex == audioPlayerState.tracks.length - 1 && - // audioPlayer.isPlaying) { - // listener(audioPlayerState.currentIndex); - // } - - // final subscription = - // audioPlayer.currentIndexChangedStream.listen(listener); - - // return subscription.cancel; - // }, - // [ - // spotify, - // playback, - // audioPlayerState.tracks, - // audioPlayerState.currentIndex, - // endlessPlayback, - // auth, - // ], - // ); + return subscription.cancel; + }, + [ + metadataPlugin, + playback, + audioPlayerState.tracks, + audioPlayerState.currentIndex, + endlessPlayback, + ], + ); } diff --git a/lib/models/metadata/metadata.freezed.dart b/lib/models/metadata/metadata.freezed.dart index 1923150b..bb4cf3f8 100644 --- a/lib/models/metadata/metadata.freezed.dart +++ b/lib/models/metadata/metadata.freezed.dart @@ -4861,6 +4861,198 @@ abstract class _PluginConfiguration extends PluginConfiguration { throw _privateConstructorUsedError; } +PluginUpdateAvailable _$PluginUpdateAvailableFromJson( + Map json) { + return _PluginUpdateAvailable.fromJson(json); +} + +/// @nodoc +mixin _$PluginUpdateAvailable { + String get downloadUrl => throw _privateConstructorUsedError; + String get version => throw _privateConstructorUsedError; + String? get changelog => throw _privateConstructorUsedError; + + /// Serializes this PluginUpdateAvailable to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of PluginUpdateAvailable + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $PluginUpdateAvailableCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $PluginUpdateAvailableCopyWith<$Res> { + factory $PluginUpdateAvailableCopyWith(PluginUpdateAvailable value, + $Res Function(PluginUpdateAvailable) then) = + _$PluginUpdateAvailableCopyWithImpl<$Res, PluginUpdateAvailable>; + @useResult + $Res call({String downloadUrl, String version, String? changelog}); +} + +/// @nodoc +class _$PluginUpdateAvailableCopyWithImpl<$Res, + $Val extends PluginUpdateAvailable> + implements $PluginUpdateAvailableCopyWith<$Res> { + _$PluginUpdateAvailableCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of PluginUpdateAvailable + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? downloadUrl = null, + Object? version = null, + Object? changelog = freezed, + }) { + return _then(_value.copyWith( + downloadUrl: null == downloadUrl + ? _value.downloadUrl + : downloadUrl // ignore: cast_nullable_to_non_nullable + as String, + version: null == version + ? _value.version + : version // ignore: cast_nullable_to_non_nullable + as String, + changelog: freezed == changelog + ? _value.changelog + : changelog // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$PluginUpdateAvailableImplCopyWith<$Res> + implements $PluginUpdateAvailableCopyWith<$Res> { + factory _$$PluginUpdateAvailableImplCopyWith( + _$PluginUpdateAvailableImpl value, + $Res Function(_$PluginUpdateAvailableImpl) then) = + __$$PluginUpdateAvailableImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String downloadUrl, String version, String? changelog}); +} + +/// @nodoc +class __$$PluginUpdateAvailableImplCopyWithImpl<$Res> + extends _$PluginUpdateAvailableCopyWithImpl<$Res, + _$PluginUpdateAvailableImpl> + implements _$$PluginUpdateAvailableImplCopyWith<$Res> { + __$$PluginUpdateAvailableImplCopyWithImpl(_$PluginUpdateAvailableImpl _value, + $Res Function(_$PluginUpdateAvailableImpl) _then) + : super(_value, _then); + + /// Create a copy of PluginUpdateAvailable + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? downloadUrl = null, + Object? version = null, + Object? changelog = freezed, + }) { + return _then(_$PluginUpdateAvailableImpl( + downloadUrl: null == downloadUrl + ? _value.downloadUrl + : downloadUrl // ignore: cast_nullable_to_non_nullable + as String, + version: null == version + ? _value.version + : version // ignore: cast_nullable_to_non_nullable + as String, + changelog: freezed == changelog + ? _value.changelog + : changelog // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$PluginUpdateAvailableImpl implements _PluginUpdateAvailable { + _$PluginUpdateAvailableImpl( + {required this.downloadUrl, required this.version, this.changelog}); + + factory _$PluginUpdateAvailableImpl.fromJson(Map json) => + _$$PluginUpdateAvailableImplFromJson(json); + + @override + final String downloadUrl; + @override + final String version; + @override + final String? changelog; + + @override + String toString() { + return 'PluginUpdateAvailable(downloadUrl: $downloadUrl, version: $version, changelog: $changelog)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$PluginUpdateAvailableImpl && + (identical(other.downloadUrl, downloadUrl) || + other.downloadUrl == downloadUrl) && + (identical(other.version, version) || other.version == version) && + (identical(other.changelog, changelog) || + other.changelog == changelog)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, downloadUrl, version, changelog); + + /// Create a copy of PluginUpdateAvailable + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$PluginUpdateAvailableImplCopyWith<_$PluginUpdateAvailableImpl> + get copyWith => __$$PluginUpdateAvailableImplCopyWithImpl< + _$PluginUpdateAvailableImpl>(this, _$identity); + + @override + Map toJson() { + return _$$PluginUpdateAvailableImplToJson( + this, + ); + } +} + +abstract class _PluginUpdateAvailable implements PluginUpdateAvailable { + factory _PluginUpdateAvailable( + {required final String downloadUrl, + required final String version, + final String? changelog}) = _$PluginUpdateAvailableImpl; + + factory _PluginUpdateAvailable.fromJson(Map json) = + _$PluginUpdateAvailableImpl.fromJson; + + @override + String get downloadUrl; + @override + String get version; + @override + String? get changelog; + + /// Create a copy of PluginUpdateAvailable + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$PluginUpdateAvailableImplCopyWith<_$PluginUpdateAvailableImpl> + get copyWith => throw _privateConstructorUsedError; +} + MetadataPluginRepository _$MetadataPluginRepositoryFromJson( Map json) { return _MetadataPluginRepository.fromJson(json); diff --git a/lib/models/metadata/metadata.g.dart b/lib/models/metadata/metadata.g.dart index 96f2833a..25ca4f9d 100644 --- a/lib/models/metadata/metadata.g.dart +++ b/lib/models/metadata/metadata.g.dart @@ -467,6 +467,21 @@ const _$PluginAbilitiesEnumMap = { PluginAbilities.authentication: 'authentication', }; +_$PluginUpdateAvailableImpl _$$PluginUpdateAvailableImplFromJson(Map json) => + _$PluginUpdateAvailableImpl( + downloadUrl: json['downloadUrl'] as String, + version: json['version'] as String, + changelog: json['changelog'] as String?, + ); + +Map _$$PluginUpdateAvailableImplToJson( + _$PluginUpdateAvailableImpl instance) => + { + 'downloadUrl': instance.downloadUrl, + 'version': instance.version, + 'changelog': instance.changelog, + }; + _$MetadataPluginRepositoryImpl _$$MetadataPluginRepositoryImplFromJson( Map json) => _$MetadataPluginRepositoryImpl( diff --git a/lib/models/metadata/plugin.dart b/lib/models/metadata/plugin.dart index 287bb2c6..9f97c53d 100644 --- a/lib/models/metadata/plugin.dart +++ b/lib/models/metadata/plugin.dart @@ -28,3 +28,15 @@ class PluginConfiguration with _$PluginConfiguration { String get slug => name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '-'); } + +@freezed +class PluginUpdateAvailable with _$PluginUpdateAvailable { + factory PluginUpdateAvailable({ + required String downloadUrl, + required String version, + String? changelog, + }) = _PluginUpdateAvailable; + + factory PluginUpdateAvailable.fromJson(Map json) => + _$PluginUpdateAvailableFromJson(json); +} diff --git a/lib/services/metadata/endpoints/artist.dart b/lib/services/metadata/endpoints/artist.dart index 188e9ea3..d008ce61 100644 --- a/lib/services/metadata/endpoints/artist.dart +++ b/lib/services/metadata/endpoints/artist.dart @@ -76,4 +76,26 @@ class MetadataPluginArtistEndpoint { positionalArgs: [ids], ); } + + Future> related( + String id, { + int? offset, + int? limit, + }) async { + final raw = await hetuMetadataArtist.invoke( + "related", + positionalArgs: [id], + namedArgs: { + "offset": offset, + "limit": limit ?? 20, + }..removeWhere((key, value) => value == null), + ) as Map; + + return SpotubePaginationResponseObject.fromJson( + raw.cast(), + (Map json) => SpotubeFullArtistObject.fromJson( + json.cast(), + ), + ); + } } diff --git a/lib/services/metadata/endpoints/track.dart b/lib/services/metadata/endpoints/track.dart index ce509a82..31535970 100644 --- a/lib/services/metadata/endpoints/track.dart +++ b/lib/services/metadata/endpoints/track.dart @@ -26,4 +26,19 @@ class MetadataPluginTrackEndpoint { Future unsave(List ids) async { await hetuMetadataTrack.invoke("unsave", positionalArgs: [ids]); } + + Future> radio(String id) async { + final result = await hetuMetadataTrack.invoke( + "radio", + positionalArgs: [id], + ); + + return (result as List) + .map( + (e) => SpotubeFullTrackObject.fromJson( + (e as Map).cast(), + ), + ) + .toList(); + } } diff --git a/lib/services/metadata/endpoints/updater.dart b/lib/services/metadata/endpoints/updater.dart new file mode 100644 index 00000000..e4800546 --- /dev/null +++ b/lib/services/metadata/endpoints/updater.dart @@ -0,0 +1,26 @@ +import 'package:hetu_script/hetu_script.dart'; +import 'package:hetu_script/values.dart'; +import 'package:spotube/models/metadata/metadata.dart'; + +class MetadataPluginUpdaterEndpoint { + final Hetu hetu; + + MetadataPluginUpdaterEndpoint(this.hetu); + + HTInstance get hetuMetadataPluginUpdater => + (hetu.fetch("metadataPlugin") as HTInstance).memberGet("updater") + as HTInstance; + + Future check(PluginConfiguration pluginConfig) async { + final result = await hetuMetadataPluginUpdater.invoke( + "check", + positionalArgs: [pluginConfig.toJson()], + ); + + return result == null + ? null + : PluginUpdateAvailable.fromJson( + (result as Map).cast(), + ); + } +}