chore: fix saving/removing tracks, albums, artists and playlists from library not working

This commit is contained in:
Kingkor Roy Tirtho 2025-09-05 16:57:29 +06:00
parent f870e12011
commit 83172f198c
13 changed files with 279 additions and 126 deletions

View File

@ -38,6 +38,7 @@ class HeartButton extends HookConsumerWidget {
child: IconButton(
variance: variance,
size: size,
enabled: onPressed != null,
icon: AnimatedSwitcher(
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
@ -74,7 +75,8 @@ class TrackHeartButton extends HookConsumerWidget {
Widget build(BuildContext context, ref) {
final savedTracks = ref.watch(metadataPluginSavedTracksProvider);
final me = ref.watch(metadataPluginUserProvider);
final (:isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref);
final (:isLiked, :isLoading, :toggleTrackLike) =
useTrackToggleLike(track, ref);
if (me.isLoading) {
return const CircularProgressIndicator();
@ -85,11 +87,11 @@ class TrackHeartButton extends HookConsumerWidget {
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
isLiked: isLiked,
onPressed: savedTracks.asData?.value != null
? () {
onPressed: savedTracks.asData?.value == null || isLoading
? null
: () {
toggleTrackLike(track);
}
: null,
},
);
}
}

View File

@ -4,6 +4,7 @@ import 'package:spotube/provider/metadata_plugin/library/tracks.dart';
typedef UseTrackToggleLike = ({
bool isLiked,
bool isLoading,
Future<void> Function(SpotubeTrackObject track) toggleTrackLike,
});
@ -11,12 +12,11 @@ UseTrackToggleLike useTrackToggleLike(SpotubeTrackObject track, WidgetRef ref) {
final savedTracksNotifier =
ref.watch(metadataPluginSavedTracksProvider.notifier);
final isSavedTrack = ref.watch(
metadataPluginIsSavedTrackProvider(track.id),
);
final isSavedTrack = ref.watch(metadataPluginIsSavedTrackProvider(track.id));
return (
isLiked: isSavedTrack.asData?.value ?? false,
isLoading: isSavedTrack.isLoading,
toggleTrackLike: (track) async {
final isLikedTrack = await ref.read(
metadataPluginIsSavedTrackProvider(track.id).future,

View File

@ -14,6 +14,7 @@ import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class HorizontalPlaybuttonCardView<T> extends HookWidget {
final Widget title;
final List<T> items;
final Widget? error;
final VoidCallback onFetchMore;
final bool isLoadingNextPage;
final bool hasNextPage;
@ -26,6 +27,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
required this.onFetchMore,
required this.isLoadingNextPage,
this.titleTrailing,
this.error,
super.key,
}) : assert(
items.every(
@ -64,54 +66,57 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
if (titleTrailing != null) titleTrailing!,
],
),
SizedBox(
height: isArtist ? 250 : 225,
child: NotificationListener(
// disable multiple scrollbar to use this
onNotification: (notification) => true,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: PointerDeviceKind.values.toSet(),
),
child: items.isEmpty
? ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 5,
itemBuilder: (context, index) {
return AlbumCard(FakeData.albumSimple);
},
)
: InfiniteList(
scrollController: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(vertical: 8.0),
itemCount: items.length,
onFetchData: onFetchMore,
loadingBuilder: (context) => Skeletonizer(
enabled: true,
child: isArtist
? ArtistCard(FakeData.artist)
: AlbumCard(FakeData.albumSimple),
),
isLoading: isLoadingNextPage,
hasReachedMax: !hasNextPage,
separatorBuilder: (context, index) => Gap(12 * scale),
itemBuilder: (context, index) {
final item = items[index];
if (error != null)
error!
else
SizedBox(
height: isArtist ? 250 : 225,
child: NotificationListener(
// disable multiple scrollbar to use this
onNotification: (notification) => true,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: PointerDeviceKind.values.toSet(),
),
child: items.isEmpty
? ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 5,
itemBuilder: (context, index) {
return AlbumCard(FakeData.albumSimple);
},
)
: InfiniteList(
scrollController: scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(vertical: 8.0),
itemCount: items.length,
onFetchData: onFetchMore,
loadingBuilder: (context) => Skeletonizer(
enabled: true,
child: isArtist
? ArtistCard(FakeData.artist)
: AlbumCard(FakeData.albumSimple),
),
isLoading: isLoadingNextPage,
hasReachedMax: !hasNextPage,
separatorBuilder: (context, index) => Gap(12 * scale),
itemBuilder: (context, index) {
final item = items[index];
return switch (item) {
SpotubeSimplePlaylistObject() =>
PlaylistCard(item as SpotubeSimplePlaylistObject),
SpotubeSimpleAlbumObject() =>
AlbumCard(item as SpotubeSimpleAlbumObject),
SpotubeFullArtistObject() =>
ArtistCard(item as SpotubeFullArtistObject),
_ => const SizedBox.shrink(),
};
}),
return switch (item) {
SpotubeSimplePlaylistObject() => PlaylistCard(
item as SpotubeSimplePlaylistObject),
SpotubeSimpleAlbumObject() =>
AlbumCard(item as SpotubeSimpleAlbumObject),
SpotubeFullArtistObject() =>
ArtistCard(item as SpotubeFullArtistObject),
_ => const SizedBox.shrink(),
};
}),
),
),
),
),
],
),
);

View File

@ -6,6 +6,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/components/track_presentation/use_track_tile_play_callback.dart';
@ -30,6 +31,19 @@ class PresentationListSection extends HookConsumerWidget {
final onTileTap = useTrackTilePlayCallback(ref);
if (state.presentationTracks.isEmpty && !options.pagination.isLoading) {
if (options.error != null) {
return SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ErrorBox(
error: options.error!,
onRetry: options.pagination.onRefresh,
),
),
),
);
}
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(8.0),

View File

@ -50,6 +50,7 @@ class TrackPresentationOptions {
final PaginationProps pagination;
final bool isLiked;
final String? shareUrl;
final Object? error;
// events
final FutureOr<bool?> Function()? onHeart; // if null heart button will hidden
@ -67,6 +68,7 @@ class TrackPresentationOptions {
this.shareUrl,
this.isLiked = false,
this.onHeart,
this.error,
}) : assert(collection is SpotubeSimpleAlbumObject ||
collection is SpotubeSimplePlaylistObject);
@ -90,7 +92,8 @@ class TrackPresentationOptions {
other.pagination == pagination &&
other.isLiked == isLiked &&
other.shareUrl == shareUrl &&
other.onHeart == onHeart;
other.onHeart == onHeart &&
other.error == error;
}
@override
@ -105,5 +108,6 @@ class TrackPresentationOptions {
pagination.hashCode ^
isLiked.hashCode ^
shareUrl.hashCode ^
onHeart.hashCode;
onHeart.hashCode ^
error.hashCode;
}

View File

@ -1,11 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/components/fallbacks/error_box.dart';
import 'package:spotube/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/album/releases.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/utils/common.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
class HomeNewReleasesSection extends HookConsumerWidget {
const HomeNewReleasesSection({super.key});
@ -24,12 +26,30 @@ class HomeNewReleasesSection extends HookConsumerWidget {
return const SizedBox.shrink();
}
if (newReleases.error
case MetadataPluginException(
errorCode: MetadataPluginErrorCode.noDefaultPlugin,
message: _,
)) {
return const SizedBox.shrink();
}
return HorizontalPlaybuttonCardView<SpotubeSimpleAlbumObject>(
items: newReleases.asData?.value.items ?? [],
title: Text(context.l10n.new_releases),
isLoadingNextPage: newReleases.isLoadingNextPage,
hasNextPage: newReleases.asData?.value.hasMore ?? false,
onFetchMore: newReleasesNotifier.fetchMore,
error: newReleases.hasError
? Center(
child: ErrorBox(
error: newReleases.error!,
onRetry: () {
ref.invalidate(metadataPluginAlbumReleasesProvider);
},
),
)
: null,
);
}
}

View File

@ -48,6 +48,7 @@ class AlbumPage extends HookConsumerWidget {
description:
"${context.l10n.released}${album.releaseDate}${album.artists.first.name}",
tracks: tracks.asData?.value.items ?? [],
error: tracks.error,
pagination: PaginationProps(
hasNextPage: tracks.asData?.value.hasMore ?? false,
isLoading: tracks.isLoading || tracks.isLoadingNextPage,

View File

@ -51,6 +51,7 @@ class LikedPlaylistPage extends HookConsumerWidget {
title: playlist.name,
description: playlist.description,
tracks: tracks,
error: likedTracks.error,
routePath: '/playlist/${playlist.id}',
isLiked: false,
shareUrl: null,

View File

@ -79,6 +79,7 @@ class PlaylistPage extends HookConsumerWidget {
owner: playlist.owner.name,
ownerImage: playlist.owner.images.lastOrNull?.url,
tracks: tracks.asData?.value.items ?? [],
error: tracks.error,
routePath: '/playlist/${playlist.id}',
isLiked: isFavoritePlaylist.asData?.value ?? false,
shareUrl: playlist.externalUri,

View File

@ -1,6 +1,6 @@
import 'package:riverpod/riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/utils/paginated.dart';
class MetadataPluginSavedAlbumNotifier
@ -18,38 +18,50 @@ class MetadataPluginSavedAlbumNotifier
@override
build() async {
ref.watch(metadataPluginProvider);
await ref.watch(metadataPluginAuthenticatedProvider.future);
return await fetch(0, 20);
}
Future<void> addFavorite(List<SpotubeSimpleAlbumObject> albums) async {
await update((state) async {
(await metadataPlugin).album.save(albums.map((e) => e.id).toList());
return state.copyWith(
items: [...state.items, ...albums],
);
});
if (albums.isEmpty || state.value == null) return;
final oldState = state.value;
for (final album in albums) {
ref.invalidate(metadataPluginIsSavedAlbumProvider(album.id));
state = AsyncData(
state.value!.copyWith(
items: [
...albums,
...state.value!.items,
],
),
);
try {
await (await metadataPlugin).album.save(albums.map((e) => e.id).toList());
} catch (e) {
state = AsyncData(oldState!);
rethrow;
}
}
Future<void> removeFavorite(List<SpotubeSimpleAlbumObject> albums) async {
await update((state) async {
final albumIds = albums.map((e) => e.id).toList();
(await metadataPlugin).album.unsave(albumIds);
return state.copyWith(
items: state.items
if (albums.isEmpty || state.value == null) return;
final oldState = state.value;
final albumIds = albums.map((e) => e.id).toList();
state = AsyncData(
state.value!.copyWith(
items: state.value!.items
.where(
(e) => albumIds.contains((e).id) == false,
)
.toList(),
);
});
for (final album in albums) {
ref.invalidate(metadataPluginIsSavedAlbumProvider(album.id));
),
);
try {
await (await metadataPlugin).album.unsave(albumIds);
} catch (e) {
state = AsyncData(oldState!);
rethrow;
}
}
}
@ -63,9 +75,14 @@ final metadataPluginSavedAlbumsProvider = AsyncNotifierProvider<
final metadataPluginIsSavedAlbumProvider =
FutureProvider.autoDispose.family<bool, String>(
(ref, albumId) async {
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
final savedAlbums =
await ref.watch(metadataPluginSavedAlbumsProvider.future);
final savedAlbumsNotifier =
ref.read(metadataPluginSavedAlbumsProvider.notifier);
final allSavedAlbums = savedAlbums.hasMore
? await savedAlbumsNotifier.fetchAll()
: savedAlbums.items;
return metadataPlugin!.user
.isSavedAlbums([albumId]).then((value) => value.first);
return allSavedAlbums.any((element) => element.id == albumId);
},
);

View File

@ -1,6 +1,6 @@
import 'package:riverpod/riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/utils/paginated.dart';
class MetadataPluginSavedArtistNotifier
@ -20,38 +20,53 @@ class MetadataPluginSavedArtistNotifier
@override
build() async {
ref.watch(metadataPluginProvider);
await ref.watch(metadataPluginAuthenticatedProvider.future);
return await fetch(0, 20);
}
Future<void> addFavorite(List<SpotubeFullArtistObject> artists) async {
await update((state) async {
(await metadataPlugin).artist.save(artists.map((e) => e.id).toList());
return state.copyWith(
items: [...state.items, ...artists],
);
});
if (artists.isEmpty || state.value == null) return;
final oldState = state.value;
for (final artist in artists) {
ref.invalidate(metadataPluginIsSavedArtistProvider(artist.id));
state = AsyncData(
state.value!.copyWith(
items: [
...artists,
...state.value!.items,
],
),
);
try {
await (await metadataPlugin)
.artist
.save(artists.map((e) => e.id).toList());
} catch (e) {
state = AsyncData(oldState!);
rethrow;
}
}
Future<void> removeFavorite(List<SpotubeFullArtistObject> artists) async {
await update((state) async {
final artistIds = artists.map((e) => e.id).toList();
(await metadataPlugin).artist.unsave(artistIds);
return state.copyWith(
items: state.items
if (artists.isEmpty || state.value == null) return;
final oldState = state.value;
final artistIds = artists.map((e) => e.id).toList();
state = AsyncData(
state.value!.copyWith(
items: state.value!.items
.where(
(e) => artistIds.contains((e).id) == false,
)
.toList(),
);
});
),
);
for (final artist in artists) {
ref.invalidate(metadataPluginIsSavedArtistProvider(artist.id));
try {
await (await metadataPlugin).artist.unsave(artistIds);
} catch (e) {
state = AsyncData(oldState!);
rethrow;
}
}
}
@ -65,9 +80,15 @@ final metadataPluginSavedArtistsProvider = AsyncNotifierProvider<
final metadataPluginIsSavedArtistProvider =
FutureProvider.autoDispose.family<bool, String>(
(ref, artistId) async {
final metadataPlugin = await ref.watch(metadataPluginProvider.future);
final savedArtists =
await ref.watch(metadataPluginSavedArtistsProvider.future);
final savedArtistsNotifier =
ref.read(metadataPluginSavedArtistsProvider.notifier);
return metadataPlugin!.user
.isSavedArtists([artistId]).then((value) => value.first);
final allSavedArtists = savedArtists.hasMore
? await savedArtistsNotifier.fetchAll()
: savedArtists.items;
return allSavedArtists.any((element) => element.id == artistId);
},
);

View File

@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.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/utils/paginated.dart';
@ -21,7 +22,8 @@ class MetadataPluginSavedPlaylistsNotifier
@override
build() async {
ref.watch(metadataPluginProvider);
await ref.watch(metadataPluginAuthenticatedProvider.future);
final playlists = await fetch(0, 20);
return playlists;
@ -42,25 +44,43 @@ class MetadataPluginSavedPlaylistsNotifier
}
Future<void> addFavorite(SpotubeSimplePlaylistObject playlist) async {
await update((state) async {
(await metadataPlugin).playlist.save(playlist.id);
return state.copyWith(
items: [...state.items, playlist],
);
});
if (state.value == null) return;
ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlist.id));
final oldState = state.value;
state = AsyncData(
state.value!.copyWith(
items: [
playlist,
...state.value!.items,
],
),
);
try {
await (await metadataPlugin).playlist.save(playlist.id);
} catch (e) {
state = AsyncData(oldState!);
rethrow;
}
}
Future<void> removeFavorite(SpotubeSimplePlaylistObject playlist) async {
await update((state) async {
(await metadataPlugin).playlist.unsave(playlist.id);
return state.copyWith(
items: state.items.where((e) => (e).id != playlist.id).toList(),
);
});
if (state.value == null) return;
ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlist.id));
final oldState = state.value;
state = AsyncData(
state.value!.copyWith(
items: state.value!.items.where((e) => (e).id != playlist.id).toList(),
),
);
try {
await (await metadataPlugin).playlist.unsave(playlist.id);
} catch (e) {
state = AsyncData(oldState!);
rethrow;
}
}
Future<void> delete(String playlistId) async {
@ -114,8 +134,16 @@ final metadataPluginIsSavedPlaylistProvider =
throw MetadataPluginException.noDefaultPlugin();
}
final follows = await plugin.user.isSavedPlaylist(id);
final savedPlaylists =
await ref.watch(metadataPluginSavedPlaylistsProvider.future);
return follows;
final savedPlaylistsNotifier =
ref.read(metadataPluginSavedPlaylistsProvider.notifier);
final allSavedPlaylists = savedPlaylists.hasMore
? await savedPlaylistsNotifier.fetchAll()
: savedPlaylists.items;
return allSavedPlaylists.any((element) => element.id == id);
},
);

View File

@ -1,6 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/utils/common.dart';
import 'package:spotube/provider/metadata_plugin/utils/paginated.dart';
@ -22,20 +22,57 @@ class MetadataPluginSavedTracksNotifier
build() async {
ref.cacheFor();
ref.watch(metadataPluginProvider);
await ref.watch(metadataPluginAuthenticatedProvider.future);
return await fetch(0, 20);
}
Future<void> addFavorite(List<SpotubeTrackObject> tracks) async {
await (await metadataPlugin).track.save(tracks.map((e) => e.id).toList());
if (state.value == null) {
return;
}
ref.invalidateSelf();
final oldState = state.value;
state = AsyncData(
state.value!.copyWith(
items: [
...tracks.whereType<SpotubeFullTrackObject>(),
...state.value!.items
],
),
);
try {
await (await metadataPlugin).track.save(tracks.map((e) => e.id).toList());
} catch (e) {
state = AsyncData(oldState!);
rethrow;
}
}
Future<void> removeFavorite(List<SpotubeTrackObject> tracks) async {
await (await metadataPlugin).track.unsave(tracks.map((e) => e.id).toList());
if (state.value == null) {
return;
}
ref.invalidateSelf();
final oldState = state.value;
state = AsyncData(
state.value!.copyWith(
items: state.value!.items
.where(
(savedTrack) => !tracks.any((track) => track.id == savedTrack.id),
)
.toList(),
),
);
try {
await (await metadataPlugin)
.track
.unsave(tracks.map((e) => e.id).toList());
} catch (e) {
state = AsyncData(oldState!);
rethrow;
}
}
}
@ -48,9 +85,11 @@ final metadataPluginSavedTracksProvider = AutoDisposeAsyncNotifierProvider<
final metadataPluginIsSavedTrackProvider =
FutureProvider.autoDispose.family<bool, String>(
(ref, trackId) async {
await ref.watch(metadataPluginSavedTracksProvider.future);
final allSavedTracks =
await ref.read(metadataPluginSavedTracksProvider.notifier).fetchAll();
final savedTracks =
await ref.watch(metadataPluginSavedTracksProvider.future);
final allSavedTracks = savedTracks.hasMore
? await ref.read(metadataPluginSavedTracksProvider.notifier).fetchAll()
: savedTracks.items;
return allSavedTracks.any((track) => track.id == trackId);
},