chore: re-enable endless playback

This commit is contained in:
Kingkor Roy Tirtho 2025-07-19 11:56:51 +06:00
parent 3e7b36f4e6
commit d2e0dc1ac9
8 changed files with 375 additions and 137 deletions

View File

@ -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/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/library/playlists.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/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/provider/metadata_plugin/core/user.dart';
import 'package:spotube/services/metadata/endpoints/error.dart'; import 'package:spotube/services/metadata/endpoints/error.dart';
@ -107,71 +106,53 @@ class TrackOptions extends HookConsumerWidget {
); );
} }
// void actionStartRadio( void actionStartRadio(
// BuildContext context, BuildContext context,
// WidgetRef ref, WidgetRef ref,
// SpotubeTrackObject track, SpotubeTrackObject track,
// ) async { ) async {
// final playback = ref.read(audioPlayerProvider.notifier); final playback = ref.read(audioPlayerProvider.notifier);
// final playlist = ref.read(audioPlayerProvider); final playlist = ref.read(audioPlayerProvider);
// final query = "${track.name} Radio"; final metadataPlugin = await ref.read(metadataPluginProvider.future);
// final metadataPlugin = await ref.read(metadataPluginProvider.future);
// if (metadataPlugin == null) { if (metadataPlugin == null) {
// throw MetadataPluginException.noDefaultPlugin( throw MetadataPluginException.noDefaultPlugin(
// "No default metadata plugin set", "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( if (context.mounted && playlist.tracks.isNotEmpty) {
// (e) { replaceQueue = await showPromptDialog(
// final validPlaylists = artists.where((a) => e.description.contains(a)); context: context,
// return e.name.contains(track.name) && title: context.l10n.how_to_start_radio,
// e.name.contains("Radio") && message: context.l10n.replace_queue_question,
// (validPlaylists.length >= 2 || okText: context.l10n.replace,
// validPlaylists.length == artists.length); cancelText: context.l10n.add_to_queue,
// }, );
// orElse: () => pages.items.first, }
// );
// bool replaceQueue = false; if (replaceQueue || playlist.tracks.isEmpty) {
await playback.stop();
await playback.load([track], autoPlay: true);
// if (context.mounted && playlist.tracks.isNotEmpty) { // we don't have to add those tracks as useEndlessPlayback will do it for us
// replaceQueue = await showPromptDialog( return;
// context: context, } else {
// title: context.l10n.how_to_start_radio, await playback.addTrack(track);
// message: context.l10n.replace_queue_question, }
// okText: context.l10n.replace,
// cancelText: context.l10n.add_to_queue,
// );
// }
// if (replaceQueue || playlist.tracks.isEmpty) { await playback.addTracks(
// await playback.stop(); tracks.toList()
// await playback.load([track], autoPlay: true); ..removeWhere((e) {
final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
// // we don't have to add those tracks as useEndlessPlayback will do it for us return e.id == track.id || isDuplicate;
// 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;
// }),
// );
// }
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -338,7 +319,7 @@ class TrackOptions extends HookConsumerWidget {
await downloadManager.addToQueue(track as SpotubeFullTrackObject); await downloadManager.addToQueue(track as SpotubeFullTrackObject);
break; break;
case TrackOptionValue.startRadio: case TrackOptionValue.startRadio:
// actionStartRadio(context, ref, track); actionStartRadio(context, ref, track);
break; break;
} }
}, },
@ -430,11 +411,11 @@ class TrackOptions extends HookConsumerWidget {
), ),
), ),
if (authenticated.asData?.value == true && !isLocalTrack) ...[ if (authenticated.asData?.value == true && !isLocalTrack) ...[
// AdaptiveMenuButton( AdaptiveMenuButton(
// value: TrackOptionValue.startRadio, value: TrackOptionValue.startRadio,
// leading: const Icon(SpotubeIcons.radio), leading: const Icon(SpotubeIcons.radio),
// child: Text(context.l10n.start_a_radio), child: Text(context.l10n.start_a_radio),
// ), ),
AdaptiveMenuButton( AdaptiveMenuButton(
value: TrackOptionValue.addToPlaylist, value: TrackOptionValue.addToPlaylist,
leading: const Icon(SpotubeIcons.playlistAdd), leading: const Icon(SpotubeIcons.playlistAdd),

View File

@ -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:spotube/services/logger/logger.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
// TODO: Implement endless playback functionality
void useEndlessPlayback(WidgetRef ref) { void useEndlessPlayback(WidgetRef ref) {
// final auth = ref.watch(authenticationProvider); final playback = ref.watch(audioPlayerProvider.notifier);
// final playback = ref.watch(audioPlayerProvider.notifier); final audioPlayerState = ref.watch(audioPlayerProvider);
// final audioPlayerState = ref.watch(audioPlayerProvider); final endlessPlayback =
// final spotify = ref.watch(spotifyProvider); ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback));
// final endlessPlayback = final metadataPlugin = ref.watch(metadataPluginProvider.future);
// ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback));
// useEffect( useEffect(
// () { () {
// if (!endlessPlayback || auth.asData?.value == null) return null; if (!endlessPlayback) return null;
// void listener(int index) async { void listener(int index) async {
// try { try {
// final playlist = ref.read(audioPlayerProvider); final playlist = ref.read(audioPlayerProvider);
// if (index != playlist.tracks.length - 1) return; if (index != playlist.tracks.length - 1) return;
// final track = playlist.tracks.last; final track = playlist.tracks.last;
// final query = "${track.name} Radio"; final tracks = await (await metadataPlugin)?.track.radio(track.id);
// final pages = await spotify.invoke((api) =>
// api.search.get(query, types: [SearchType.playlist]).first());
// final radios = pages if (tracks == null || tracks.isEmpty) return;
// .expand((e) => e.items?.toList() ?? <PlaylistSimple>[])
// .toList()
// .cast<PlaylistSimple>();
// final artists = track.artists.map((e) => e.name); await playback.addTracks(
tracks.toList()
// final radio = radios.firstWhere( ..removeWhere((e) {
// (e) { final playlist = ref.read(audioPlayerProvider);
// final validPlaylists = final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
// artists.where((a) => e.description!.contains(a)); return e.id == track.id || isDuplicate;
// return e.name == "${track.name} Radio" && }),
// (validPlaylists.length >= 2 || );
// validPlaylists.length == artists.length) && } catch (e, stack) {
// e.owner?.displayName != "Spotify"; AppLogger.reportError(e, stack);
// }, }
// orElse: () => radios.first, }
// );
// Sometimes user can change settings for which the currentIndexChanged
// final tracks = // might not be called. So we need to check if the current track is the
// ref.read(metadataPluginPlaylistTracksProvider(radio.id!)); // last track and if it is then we need to call the listener manually.
if (audioPlayerState.currentIndex == audioPlayerState.tracks.length - 1 &&
// await playback.addTracks( audioPlayer.isPlaying) {
// tracks.toList() listener(audioPlayerState.currentIndex);
// ..removeWhere((e) { }
// final playlist = ref.read(audioPlayerProvider);
// final isDuplicate = playlist.tracks.any((t) => t.id == e.id); final subscription =
// return e.id == track.id || isDuplicate; audioPlayer.currentIndexChangedStream.listen(listener);
// }),
// ); return subscription.cancel;
// } catch (e, stack) { },
// AppLogger.reportError(e, stack); [
// } metadataPlugin,
// } playback,
audioPlayerState.tracks,
// // Sometimes user can change settings for which the currentIndexChanged audioPlayerState.currentIndex,
// // might not be called. So we need to check if the current track is the endlessPlayback,
// // 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,
// ],
// );
} }

View File

@ -4861,6 +4861,198 @@ abstract class _PluginConfiguration extends PluginConfiguration {
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
PluginUpdateAvailable _$PluginUpdateAvailableFromJson(
Map<String, dynamic> 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<String, dynamic> 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<PluginUpdateAvailable> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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( MetadataPluginRepository _$MetadataPluginRepositoryFromJson(
Map<String, dynamic> json) { Map<String, dynamic> json) {
return _MetadataPluginRepository.fromJson(json); return _MetadataPluginRepository.fromJson(json);

View File

@ -467,6 +467,21 @@ const _$PluginAbilitiesEnumMap = {
PluginAbilities.authentication: 'authentication', PluginAbilities.authentication: 'authentication',
}; };
_$PluginUpdateAvailableImpl _$$PluginUpdateAvailableImplFromJson(Map json) =>
_$PluginUpdateAvailableImpl(
downloadUrl: json['downloadUrl'] as String,
version: json['version'] as String,
changelog: json['changelog'] as String?,
);
Map<String, dynamic> _$$PluginUpdateAvailableImplToJson(
_$PluginUpdateAvailableImpl instance) =>
<String, dynamic>{
'downloadUrl': instance.downloadUrl,
'version': instance.version,
'changelog': instance.changelog,
};
_$MetadataPluginRepositoryImpl _$$MetadataPluginRepositoryImplFromJson( _$MetadataPluginRepositoryImpl _$$MetadataPluginRepositoryImplFromJson(
Map json) => Map json) =>
_$MetadataPluginRepositoryImpl( _$MetadataPluginRepositoryImpl(

View File

@ -28,3 +28,15 @@ class PluginConfiguration with _$PluginConfiguration {
String get slug => name.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '-'); 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<String, dynamic> json) =>
_$PluginUpdateAvailableFromJson(json);
}

View File

@ -76,4 +76,26 @@ class MetadataPluginArtistEndpoint {
positionalArgs: [ids], positionalArgs: [ids],
); );
} }
Future<SpotubePaginationResponseObject<SpotubeFullArtistObject>> 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<SpotubeFullArtistObject>.fromJson(
raw.cast<String, dynamic>(),
(Map json) => SpotubeFullArtistObject.fromJson(
json.cast<String, dynamic>(),
),
);
}
} }

View File

@ -26,4 +26,19 @@ class MetadataPluginTrackEndpoint {
Future<void> unsave(List<String> ids) async { Future<void> unsave(List<String> ids) async {
await hetuMetadataTrack.invoke("unsave", positionalArgs: [ids]); await hetuMetadataTrack.invoke("unsave", positionalArgs: [ids]);
} }
Future<List<SpotubeFullTrackObject>> radio(String id) async {
final result = await hetuMetadataTrack.invoke(
"radio",
positionalArgs: [id],
);
return (result as List)
.map(
(e) => SpotubeFullTrackObject.fromJson(
(e as Map).cast<String, dynamic>(),
),
)
.toList();
}
} }

View File

@ -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<PluginUpdateAvailable?> check(PluginConfiguration pluginConfig) async {
final result = await hetuMetadataPluginUpdater.invoke(
"check",
positionalArgs: [pluginConfig.toJson()],
);
return result == null
? null
: PluginUpdateAvailable.fromJson(
(result as Map).cast<String, dynamic>(),
);
}
}