Compare commits

...

4 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
d22b5349a3 chore: fix pagination not working 2025-09-05 17:06:12 +06:00
Kingkor Roy Tirtho
83172f198c chore: fix saving/removing tracks, albums, artists and playlists from library not working 2025-09-05 16:57:29 +06:00
Kingkor Roy Tirtho
f870e12011 fix(playback): skip network requests if cached file already exists 2025-09-05 11:03:56 +06:00
Kingkor Roy Tirtho
345c6ac714 chore: fix pagination for liked tracks and saved albums not working 2025-09-05 10:50:14 +06:00
17 changed files with 381 additions and 228 deletions

View File

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

View File

@ -4,6 +4,7 @@ import 'package:spotube/provider/metadata_plugin/library/tracks.dart';
typedef UseTrackToggleLike = ({ typedef UseTrackToggleLike = ({
bool isLiked, bool isLiked,
bool isLoading,
Future<void> Function(SpotubeTrackObject track) toggleTrackLike, Future<void> Function(SpotubeTrackObject track) toggleTrackLike,
}); });
@ -11,12 +12,11 @@ UseTrackToggleLike useTrackToggleLike(SpotubeTrackObject track, WidgetRef ref) {
final savedTracksNotifier = final savedTracksNotifier =
ref.watch(metadataPluginSavedTracksProvider.notifier); ref.watch(metadataPluginSavedTracksProvider.notifier);
final isSavedTrack = ref.watch( final isSavedTrack = ref.watch(metadataPluginIsSavedTrackProvider(track.id));
metadataPluginIsSavedTrackProvider(track.id),
);
return ( return (
isLiked: isSavedTrack.asData?.value ?? false, isLiked: isSavedTrack.asData?.value ?? false,
isLoading: isSavedTrack.isLoading,
toggleTrackLike: (track) async { toggleTrackLike: (track) async {
final isLikedTrack = await ref.read( final isLikedTrack = await ref.read(
metadataPluginIsSavedTrackProvider(track.id).future, 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 { class HorizontalPlaybuttonCardView<T> extends HookWidget {
final Widget title; final Widget title;
final List<T> items; final List<T> items;
final Widget? error;
final VoidCallback onFetchMore; final VoidCallback onFetchMore;
final bool isLoadingNextPage; final bool isLoadingNextPage;
final bool hasNextPage; final bool hasNextPage;
@ -26,6 +27,7 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
required this.onFetchMore, required this.onFetchMore,
required this.isLoadingNextPage, required this.isLoadingNextPage,
this.titleTrailing, this.titleTrailing,
this.error,
super.key, super.key,
}) : assert( }) : assert(
items.every( items.every(
@ -64,54 +66,57 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
if (titleTrailing != null) titleTrailing!, if (titleTrailing != null) titleTrailing!,
], ],
), ),
SizedBox( if (error != null)
height: isArtist ? 250 : 225, error!
child: NotificationListener( else
// disable multiple scrollbar to use this SizedBox(
onNotification: (notification) => true, height: isArtist ? 250 : 225,
child: ScrollConfiguration( child: NotificationListener(
behavior: ScrollConfiguration.of(context).copyWith( // disable multiple scrollbar to use this
dragDevices: PointerDeviceKind.values.toSet(), onNotification: (notification) => true,
), child: ScrollConfiguration(
child: items.isEmpty behavior: ScrollConfiguration.of(context).copyWith(
? ListView.builder( dragDevices: PointerDeviceKind.values.toSet(),
scrollDirection: Axis.horizontal, ),
itemCount: 5, child: items.isEmpty
itemBuilder: (context, index) { ? ListView.builder(
return AlbumCard(FakeData.albumSimple); scrollDirection: Axis.horizontal,
}, itemCount: 5,
) itemBuilder: (context, index) {
: InfiniteList( return AlbumCard(FakeData.albumSimple);
scrollController: scrollController, },
scrollDirection: Axis.horizontal, )
padding: const EdgeInsets.symmetric(vertical: 8.0), : InfiniteList(
itemCount: items.length, scrollController: scrollController,
onFetchData: onFetchMore, scrollDirection: Axis.horizontal,
loadingBuilder: (context) => Skeletonizer( padding: const EdgeInsets.symmetric(vertical: 8.0),
enabled: true, itemCount: items.length,
child: isArtist onFetchData: onFetchMore,
? ArtistCard(FakeData.artist) loadingBuilder: (context) => Skeletonizer(
: AlbumCard(FakeData.albumSimple), enabled: true,
), child: isArtist
isLoading: isLoadingNextPage, ? ArtistCard(FakeData.artist)
hasReachedMax: !hasNextPage, : AlbumCard(FakeData.albumSimple),
separatorBuilder: (context, index) => Gap(12 * scale), ),
itemBuilder: (context, index) { isLoading: isLoadingNextPage,
final item = items[index]; hasReachedMax: !hasNextPage,
separatorBuilder: (context, index) => Gap(12 * scale),
itemBuilder: (context, index) {
final item = items[index];
return switch (item) { return switch (item) {
SpotubeSimplePlaylistObject() => SpotubeSimplePlaylistObject() => PlaylistCard(
PlaylistCard(item as SpotubeSimplePlaylistObject), item as SpotubeSimplePlaylistObject),
SpotubeSimpleAlbumObject() => SpotubeSimpleAlbumObject() =>
AlbumCard(item as SpotubeSimpleAlbumObject), AlbumCard(item as SpotubeSimpleAlbumObject),
SpotubeFullArtistObject() => SpotubeFullArtistObject() =>
ArtistCard(item as SpotubeFullArtistObject), ArtistCard(item as SpotubeFullArtistObject),
_ => const SizedBox.shrink(), _ => 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:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.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_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart'; import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/components/track_presentation/use_track_tile_play_callback.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); final onTileTap = useTrackTilePlayCallback(ref);
if (state.presentationTracks.isEmpty && !options.pagination.isLoading) { 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( return SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),

View File

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

View File

@ -1,11 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.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/components/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/album/releases.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/core/auth.dart';
import 'package:spotube/provider/metadata_plugin/utils/common.dart'; import 'package:spotube/provider/metadata_plugin/utils/common.dart';
import 'package:spotube/services/metadata/errors/exceptions.dart';
class HomeNewReleasesSection extends HookConsumerWidget { class HomeNewReleasesSection extends HookConsumerWidget {
const HomeNewReleasesSection({super.key}); const HomeNewReleasesSection({super.key});
@ -24,12 +26,30 @@ class HomeNewReleasesSection extends HookConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (newReleases.error
case MetadataPluginException(
errorCode: MetadataPluginErrorCode.noDefaultPlugin,
message: _,
)) {
return const SizedBox.shrink();
}
return HorizontalPlaybuttonCardView<SpotubeSimpleAlbumObject>( return HorizontalPlaybuttonCardView<SpotubeSimpleAlbumObject>(
items: newReleases.asData?.value.items ?? [], items: newReleases.asData?.value.items ?? [],
title: Text(context.l10n.new_releases), title: Text(context.l10n.new_releases),
isLoadingNextPage: newReleases.isLoadingNextPage, isLoadingNextPage: newReleases.isLoadingNextPage,
hasNextPage: newReleases.asData?.value.hasMore ?? false, hasNextPage: newReleases.asData?.value.hasMore ?? false,
onFetchMore: newReleasesNotifier.fetchMore, 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: description:
"${context.l10n.released}${album.releaseDate}${album.artists.first.name}", "${context.l10n.released}${album.releaseDate}${album.artists.first.name}",
tracks: tracks.asData?.value.items ?? [], tracks: tracks.asData?.value.items ?? [],
error: tracks.error,
pagination: PaginationProps( pagination: PaginationProps(
hasNextPage: tracks.asData?.value.hasMore ?? false, hasNextPage: tracks.asData?.value.hasMore ?? false,
isLoading: tracks.isLoading || tracks.isLoadingNextPage, isLoading: tracks.isLoading || tracks.isLoadingNextPage,

View File

@ -99,7 +99,7 @@ class UserAlbumsPage extends HookConsumerWidget {
features: const [ features: const [
InputFeature.leading(Icon(SpotubeIcons.filter)) InputFeature.leading(Icon(SpotubeIcons.filter))
], ],
placeholder: Text(context.l10n.filter_artist), placeholder: Text(context.l10n.filter_albums),
), ),
), ),
), ),
@ -121,7 +121,7 @@ class UserAlbumsPage extends HookConsumerWidget {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
Text( Text(
context.l10n.not_following_artists, context.l10n.no_favorite_albums_yet,
textAlign: TextAlign.center, textAlign: TextAlign.center,
).muted().small() ).muted().small()
], ],

View File

@ -8,6 +8,7 @@ import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/pages/playlist/playlist.dart'; import 'package:spotube/pages/playlist/playlist.dart';
import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; import 'package:spotube/provider/metadata_plugin/library/tracks.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:spotube/provider/metadata_plugin/utils/common.dart';
@RoutePage() @RoutePage()
class LikedPlaylistPage extends HookConsumerWidget { class LikedPlaylistPage extends HookConsumerWidget {
@ -22,6 +23,8 @@ class LikedPlaylistPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final likedTracks = ref.watch(metadataPluginSavedTracksProvider); final likedTracks = ref.watch(metadataPluginSavedTracksProvider);
final likedTracksNotifier =
ref.watch(metadataPluginSavedTracksProvider.notifier);
final tracks = likedTracks.asData?.value.items ?? []; final tracks = likedTracks.asData?.value.items ?? [];
return material.RefreshIndicator.adaptive( return material.RefreshIndicator.adaptive(
@ -33,11 +36,13 @@ class LikedPlaylistPage extends HookConsumerWidget {
collection: playlist, collection: playlist,
image: Assets.images.likedTracks.path, image: Assets.images.likedTracks.path,
pagination: PaginationProps( pagination: PaginationProps(
hasNextPage: false, hasNextPage: likedTracks.asData?.value.hasMore ?? false,
isLoading: likedTracks.isLoading, isLoading: likedTracks.isLoadingNextPage && !likedTracks.isLoading,
onFetchMore: () {}, onFetchMore: () async {
await likedTracksNotifier.fetchMore();
},
onFetchAll: () async { onFetchAll: () async {
return tracks.toList(); return await likedTracksNotifier.fetchAll();
}, },
onRefresh: () async { onRefresh: () async {
ref.invalidate(metadataPluginSavedTracksProvider); ref.invalidate(metadataPluginSavedTracksProvider);
@ -46,6 +51,7 @@ class LikedPlaylistPage extends HookConsumerWidget {
title: playlist.name, title: playlist.name,
description: playlist.description, description: playlist.description,
tracks: tracks, tracks: tracks,
error: likedTracks.error,
routePath: '/playlist/${playlist.id}', routePath: '/playlist/${playlist.id}',
isLiked: false, isLiked: false,
shareUrl: null, shareUrl: null,

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import 'package:riverpod/riverpod.dart'; import 'package:riverpod/riverpod.dart';
import 'package:spotube/models/metadata/metadata.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'; import 'package:spotube/provider/metadata_plugin/utils/paginated.dart';
class MetadataPluginSavedArtistNotifier class MetadataPluginSavedArtistNotifier
@ -20,38 +20,53 @@ class MetadataPluginSavedArtistNotifier
@override @override
build() async { build() async {
ref.watch(metadataPluginProvider); await ref.watch(metadataPluginAuthenticatedProvider.future);
return await fetch(0, 20); return await fetch(0, 20);
} }
Future<void> addFavorite(List<SpotubeFullArtistObject> artists) async { Future<void> addFavorite(List<SpotubeFullArtistObject> artists) async {
await update((state) async { if (artists.isEmpty || state.value == null) return;
(await metadataPlugin).artist.save(artists.map((e) => e.id).toList()); final oldState = state.value;
return state.copyWith(
items: [...state.items, ...artists],
);
});
for (final artist in artists) { state = AsyncData(
ref.invalidate(metadataPluginIsSavedArtistProvider(artist.id)); 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 { Future<void> removeFavorite(List<SpotubeFullArtistObject> artists) async {
await update((state) async { if (artists.isEmpty || state.value == null) return;
final artistIds = artists.map((e) => e.id).toList();
(await metadataPlugin).artist.unsave(artistIds); final oldState = state.value;
return state.copyWith(
items: state.items final artistIds = artists.map((e) => e.id).toList();
state = AsyncData(
state.value!.copyWith(
items: state.value!.items
.where( .where(
(e) => artistIds.contains((e).id) == false, (e) => artistIds.contains((e).id) == false,
) )
.toList(), .toList(),
); ),
}); );
for (final artist in artists) { try {
ref.invalidate(metadataPluginIsSavedArtistProvider(artist.id)); await (await metadataPlugin).artist.unsave(artistIds);
} catch (e) {
state = AsyncData(oldState!);
rethrow;
} }
} }
} }
@ -65,9 +80,15 @@ final metadataPluginSavedArtistsProvider = AsyncNotifierProvider<
final metadataPluginIsSavedArtistProvider = final metadataPluginIsSavedArtistProvider =
FutureProvider.autoDispose.family<bool, String>( FutureProvider.autoDispose.family<bool, String>(
(ref, artistId) async { (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 final allSavedArtists = savedArtists.hasMore
.isSavedArtists([artistId]).then((value) => value.first); ? 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:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/models/metadata/metadata.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/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart'; import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart';
import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; import 'package:spotube/provider/metadata_plugin/utils/paginated.dart';
@ -21,7 +22,8 @@ class MetadataPluginSavedPlaylistsNotifier
@override @override
build() async { build() async {
ref.watch(metadataPluginProvider); await ref.watch(metadataPluginAuthenticatedProvider.future);
final playlists = await fetch(0, 20); final playlists = await fetch(0, 20);
return playlists; return playlists;
@ -42,25 +44,43 @@ class MetadataPluginSavedPlaylistsNotifier
} }
Future<void> addFavorite(SpotubeSimplePlaylistObject playlist) async { Future<void> addFavorite(SpotubeSimplePlaylistObject playlist) async {
await update((state) async { if (state.value == null) return;
(await metadataPlugin).playlist.save(playlist.id);
return state.copyWith(
items: [...state.items, playlist],
);
});
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 { Future<void> removeFavorite(SpotubeSimplePlaylistObject playlist) async {
await update((state) async { if (state.value == null) return;
(await metadataPlugin).playlist.unsave(playlist.id);
return state.copyWith(
items: state.items.where((e) => (e).id != playlist.id).toList(),
);
});
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 { Future<void> delete(String playlistId) async {
@ -114,8 +134,16 @@ final metadataPluginIsSavedPlaylistProvider =
throw MetadataPluginException.noDefaultPlugin(); 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.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/common.dart';
import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; import 'package:spotube/provider/metadata_plugin/utils/paginated.dart';
@ -22,20 +22,57 @@ class MetadataPluginSavedTracksNotifier
build() async { build() async {
ref.cacheFor(); ref.cacheFor();
ref.watch(metadataPluginProvider); await ref.watch(metadataPluginAuthenticatedProvider.future);
return await fetch(0, 20); return await fetch(0, 20);
} }
Future<void> addFavorite(List<SpotubeTrackObject> tracks) async { 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 { 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 = final metadataPluginIsSavedTrackProvider =
FutureProvider.autoDispose.family<bool, String>( FutureProvider.autoDispose.family<bool, String>(
(ref, trackId) async { (ref, trackId) async {
await ref.watch(metadataPluginSavedTracksProvider.future); final savedTracks =
final allSavedTracks = await ref.watch(metadataPluginSavedTracksProvider.future);
await ref.read(metadataPluginSavedTracksProvider.notifier).fetchAll(); final allSavedTracks = savedTracks.hasMore
? await ref.read(metadataPluginSavedTracksProvider.notifier).fetchAll()
: savedTracks.items;
return allSavedTracks.any((track) => track.id == trackId); return allSavedTracks.any((track) => track.id == trackId);
}, },

View File

@ -12,22 +12,24 @@ abstract class FamilyPaginatedAsyncNotifier<K, A>
Future<void> fetchMore() async { Future<void> fetchMore() async {
if (state.value == null || !state.value!.hasMore) return; if (state.value == null || !state.value!.hasMore) return;
state = AsyncLoadingNext(state.asData!.value); final oldState = state.value;
state = await AsyncValue.guard( try {
() async { state = AsyncLoadingNext(state.asData!.value);
final newState = await fetch(
state.value!.nextOffset!,
state.value!.limit,
);
final oldItems = final newState = await fetch(
state.value!.items.isEmpty ? <K>[] : state.value!.items.cast<K>(); state.value!.nextOffset!,
final items = newState.items.isEmpty ? <K>[] : newState.items.cast<K>(); state.value!.limit,
);
return newState.copyWith(items: <K>[...oldItems, ...items]); final oldItems =
}, state.value!.items.isEmpty ? <K>[] : state.value!.items.cast<K>();
); final items = newState.items.isEmpty ? <K>[] : newState.items.cast<K>();
state = AsyncData(newState.copyWith(items: <K>[...oldItems, ...items]));
} finally {
state = AsyncData(oldState!);
}
} }
Future<List<K>> fetchAll() async { Future<List<K>> fetchAll() async {
@ -60,21 +62,25 @@ abstract class AutoDisposeFamilyPaginatedAsyncNotifier<K, A>
Future<void> fetchMore() async { Future<void> fetchMore() async {
if (state.value == null || !state.value!.hasMore) return; if (state.value == null || !state.value!.hasMore) return;
final oldState = state.value;
state = AsyncLoadingNext(state.asData!.value); try {
state = AsyncLoadingNext(state.value!);
state = await AsyncValue.guard( final newState = await fetch(
() async { state.value!.nextOffset!,
final newState = await fetch( state.value!.limit,
state.value!.nextOffset!, );
state.value!.limit,
); state = AsyncData(
return newState.copyWith(items: [ newState.copyWith(items: [
...state.value!.items.cast<K>(), ...state.value!.items.cast<K>(),
...newState.items.cast<K>(), ...newState.items.cast<K>(),
]); ]),
}, );
); } finally {
state = AsyncData(oldState!);
}
} }
Future<List<K>> fetchAll() async { Future<List<K>> fetchAll() async {

View File

@ -14,22 +14,23 @@ mixin PaginatedAsyncNotifierMixin<K>
Future<void> fetchMore() async { Future<void> fetchMore() async {
if (state.value == null || !state.value!.hasMore) return; if (state.value == null || !state.value!.hasMore) return;
state = AsyncLoadingNext(state.asData!.value); final oldState = state.value;
try {
state = AsyncLoadingNext(state.asData!.value);
state = await AsyncValue.guard( final newState = await fetch(
() async { state.value!.nextOffset!,
final newState = await fetch( state.value!.limit,
state.value!.nextOffset!, );
state.value!.limit,
);
final oldItems = final oldItems =
state.value!.items.isEmpty ? <K>[] : state.value!.items.cast<K>(); state.value!.items.isEmpty ? <K>[] : state.value!.items.cast<K>();
final items = newState.items.isEmpty ? <K>[] : newState.items.cast<K>(); final items = newState.items.isEmpty ? <K>[] : newState.items.cast<K>();
return newState.copyWith(items: <K>[...oldItems, ...items]); state = AsyncData(newState.copyWith(items: <K>[...oldItems, ...items]));
}, } finally {
); state = AsyncData(oldState!);
}
} }
Future<List<K>> fetchAll() async { Future<List<K>> fetchAll() async {

View File

@ -48,6 +48,7 @@ class ServerPlaybackRoutes {
Future<({dio_lib.Response<Uint8List> response, Uint8List? bytes})> Future<({dio_lib.Response<Uint8List> response, Uint8List? bytes})>
streamTrack( streamTrack(
Request request,
SourcedTrack track, SourcedTrack track,
Map<String, dynamic> headers, Map<String, dynamic> headers,
) async { ) async {
@ -59,35 +60,6 @@ class ServerPlaybackRoutes {
), ),
), ),
); );
final trackPartialCacheFile = File("${trackCacheFile.path}.part");
String? url = track.url;
url ??= await ref
.read(trackSourcesProvider(track.query).notifier)
.swapWithNextSibling()
.then((track) => track.url!);
var options = Options(
headers: {
...headers,
"user-agent": _randomUserAgent,
"Cache-Control": "max-age=3600",
"Connection": "keep-alive",
"host": Uri.parse(url!).host,
},
responseType: ResponseType.bytes,
validateStatus: (status) => status! < 400,
);
final headersRes = await Future<dio_lib.Response?>.value(
dio.head(
url,
options: options,
),
).catchError((_) async => null);
final contentLength = headersRes?.headers.value("content-length");
if (await trackCacheFile.exists() && userPreferences.cacheMusic) { if (await trackCacheFile.exists() && userPreferences.cacheMusic) {
final bytes = await trackCacheFile.readAsBytes(); final bytes = await trackCacheFile.readAsBytes();
@ -102,12 +74,51 @@ class ServerPlaybackRoutes {
"accept-ranges": ["bytes"], "accept-ranges": ["bytes"],
"content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"], "content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"],
}), }),
requestOptions: RequestOptions(path: url), requestOptions: RequestOptions(path: request.requestedUri.toString()),
), ),
bytes: bytes, bytes: bytes,
); );
} }
final trackPartialCacheFile = File("${trackCacheFile.path}.part");
String url = track.url ??
await ref
.read(trackSourcesProvider(track.query).notifier)
.swapWithNextSibling()
.then((track) => track.url!);
var options = Options(
headers: {
...headers,
"user-agent": _randomUserAgent,
"Cache-Control": "max-age=3600",
"Connection": "keep-alive",
"host": Uri.parse(url).host,
},
responseType: ResponseType.bytes,
validateStatus: (status) => status! < 400,
);
final contentLengthRes = await Future<dio_lib.Response?>.value(
dio.head(
url,
options: options,
),
).catchError((e, stack) async {
AppLogger.reportError(e, stack);
final sourcedTrack = await ref
.read(trackSourcesProvider(track.query).notifier)
.refreshStreamingUrl();
url = sourcedTrack.url!;
return dio.head(url, options: options);
});
final contentLength = contentLengthRes?.headers.value("content-length");
/// Forcing partial content range as mpv sometimes greedily wants /// Forcing partial content range as mpv sometimes greedily wants
/// everything at one go. Slows down overall streaming. /// everything at one go. Slows down overall streaming.
final range = RangeHeader.parse(headers["range"] ?? ""); final range = RangeHeader.parse(headers["range"] ?? "");
@ -123,33 +134,7 @@ class ServerPlaybackRoutes {
); );
} }
final res = await dio final res = await dio.get<Uint8List>(url, options: options);
.get<Uint8List>(
url,
options: options.copyWith(headers: {
...?options.headers,
"user-agent": _randomUserAgent,
}),
)
.catchError((e, stack) async {
AppLogger.reportError(e, stack);
final sourcedTrack = await ref
.read(trackSourcesProvider(track.query).notifier)
.refreshStreamingUrl();
// It gets updated by itself.
// if (playlist.activeTrack?.id == sourcedTrack.query.id) {
// ref.read(activeTrackSourcesProvider.notifier).update(sourcedTrack);
// }
return await dio.get<Uint8List>(
sourcedTrack.url!,
options: options.copyWith(headers: {
...?options.headers,
"user-agent": _randomUserAgent,
}),
);
});
final bytes = res.data; final bytes = res.data;
@ -228,8 +213,11 @@ class ServerPlaybackRoutes {
).future, ).future,
); );
final (bytes: audioBytes, response: res) = final (bytes: audioBytes, response: res) = await streamTrack(
await streamTrack(sourcedTrack!, request.headers); request,
sourcedTrack!,
request.headers,
);
return Response( return Response(
res.statusCode!, res.statusCode!,