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(
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

@ -99,7 +99,7 @@ class UserAlbumsPage extends HookConsumerWidget {
features: const [
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,
),
Text(
context.l10n.not_following_artists,
context.l10n.no_favorite_albums_yet,
textAlign: TextAlign.center,
).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/provider/metadata_plugin/library/tracks.dart';
import 'package:auto_route/auto_route.dart';
import 'package:spotube/provider/metadata_plugin/utils/common.dart';
@RoutePage()
class LikedPlaylistPage extends HookConsumerWidget {
@ -22,6 +23,8 @@ class LikedPlaylistPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final likedTracks = ref.watch(metadataPluginSavedTracksProvider);
final likedTracksNotifier =
ref.watch(metadataPluginSavedTracksProvider.notifier);
final tracks = likedTracks.asData?.value.items ?? [];
return material.RefreshIndicator.adaptive(
@ -33,11 +36,13 @@ class LikedPlaylistPage extends HookConsumerWidget {
collection: playlist,
image: Assets.images.likedTracks.path,
pagination: PaginationProps(
hasNextPage: false,
isLoading: likedTracks.isLoading,
onFetchMore: () {},
hasNextPage: likedTracks.asData?.value.hasMore ?? false,
isLoading: likedTracks.isLoadingNextPage && !likedTracks.isLoading,
onFetchMore: () async {
await likedTracksNotifier.fetchMore();
},
onFetchAll: () async {
return tracks.toList();
return await likedTracksNotifier.fetchAll();
},
onRefresh: () async {
ref.invalidate(metadataPluginSavedTracksProvider);
@ -46,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);
},

View File

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

View File

@ -14,22 +14,23 @@ mixin PaginatedAsyncNotifierMixin<K>
Future<void> fetchMore() async {
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(
() async {
final newState = await fetch(
state.value!.nextOffset!,
state.value!.limit,
);
final newState = await fetch(
state.value!.nextOffset!,
state.value!.limit,
);
final oldItems =
state.value!.items.isEmpty ? <K>[] : state.value!.items.cast<K>();
final items = newState.items.isEmpty ? <K>[] : newState.items.cast<K>();
final oldItems =
state.value!.items.isEmpty ? <K>[] : state.value!.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 {

View File

@ -48,6 +48,7 @@ class ServerPlaybackRoutes {
Future<({dio_lib.Response<Uint8List> response, Uint8List? bytes})>
streamTrack(
Request request,
SourcedTrack track,
Map<String, dynamic> headers,
) 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) {
final bytes = await trackCacheFile.readAsBytes();
@ -102,12 +74,51 @@ class ServerPlaybackRoutes {
"accept-ranges": ["bytes"],
"content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"],
}),
requestOptions: RequestOptions(path: url),
requestOptions: RequestOptions(path: request.requestedUri.toString()),
),
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
/// everything at one go. Slows down overall streaming.
final range = RangeHeader.parse(headers["range"] ?? "");
@ -123,33 +134,7 @@ class ServerPlaybackRoutes {
);
}
final res = await dio
.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 res = await dio.get<Uint8List>(url, options: options);
final bytes = res.data;
@ -228,8 +213,11 @@ class ServerPlaybackRoutes {
).future,
);
final (bytes: audioBytes, response: res) =
await streamTrack(sourcedTrack!, request.headers);
final (bytes: audioBytes, response: res) = await streamTrack(
request,
sourcedTrack!,
request.headers,
);
return Response(
res.statusCode!,