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/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),

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: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() ?? <PlaylistSimple>[])
// .toList()
// .cast<PlaylistSimple>();
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,
],
);
}

View File

@ -4861,6 +4861,198 @@ abstract class _PluginConfiguration extends PluginConfiguration {
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(
Map<String, dynamic> json) {
return _MetadataPluginRepository.fromJson(json);

View File

@ -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<String, dynamic> _$$PluginUpdateAvailableImplToJson(
_$PluginUpdateAvailableImpl instance) =>
<String, dynamic>{
'downloadUrl': instance.downloadUrl,
'version': instance.version,
'changelog': instance.changelog,
};
_$MetadataPluginRepositoryImpl _$$MetadataPluginRepositoryImplFromJson(
Map json) =>
_$MetadataPluginRepositoryImpl(

View File

@ -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<String, dynamic> json) =>
_$PluginUpdateAvailableFromJson(json);
}

View File

@ -76,4 +76,26 @@ class MetadataPluginArtistEndpoint {
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 {
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>(),
);
}
}