Compare commits

..

1 Commits

Author SHA1 Message Date
Richard Hajek
b5681b4fd1
Merge 42e954428b into 005355e267 2025-09-03 19:18:20 +06:00
17 changed files with 217 additions and 355 deletions

View File

@ -38,7 +38,6 @@ 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,
@ -75,8 +74,7 @@ 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, :isLoading, :toggleTrackLike) = final (:isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref);
useTrackToggleLike(track, ref);
if (me.isLoading) { if (me.isLoading) {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
@ -87,11 +85,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 || isLoading onPressed: savedTracks.asData?.value != null
? null ? () {
: () {
toggleTrackLike(track); toggleTrackLike(track);
}, }
: null,
); );
} }
} }

View File

@ -4,7 +4,6 @@ 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,
}); });
@ -12,11 +11,12 @@ UseTrackToggleLike useTrackToggleLike(SpotubeTrackObject track, WidgetRef ref) {
final savedTracksNotifier = final savedTracksNotifier =
ref.watch(metadataPluginSavedTracksProvider.notifier); ref.watch(metadataPluginSavedTracksProvider.notifier);
final isSavedTrack = ref.watch(metadataPluginIsSavedTrackProvider(track.id)); final isSavedTrack = ref.watch(
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,7 +14,6 @@ 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;
@ -27,7 +26,6 @@ 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(
@ -66,57 +64,54 @@ class HorizontalPlaybuttonCardView<T> extends HookWidget {
if (titleTrailing != null) titleTrailing!, if (titleTrailing != null) titleTrailing!,
], ],
), ),
if (error != null) SizedBox(
error! height: isArtist ? 250 : 225,
else child: NotificationListener(
SizedBox( // disable multiple scrollbar to use this
height: isArtist ? 250 : 225, onNotification: (notification) => true,
child: NotificationListener( child: ScrollConfiguration(
// disable multiple scrollbar to use this behavior: ScrollConfiguration.of(context).copyWith(
onNotification: (notification) => true, dragDevices: PointerDeviceKind.values.toSet(),
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(),
};
}),
), ),
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(),
};
}),
), ),
), ),
),
], ],
), ),
); );

View File

@ -6,7 +6,6 @@ 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';
@ -31,19 +30,6 @@ 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,7 +50,6 @@ 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
@ -68,7 +67,6 @@ 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);
@ -92,8 +90,7 @@ 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
@ -108,6 +105,5 @@ class TrackPresentationOptions {
pagination.hashCode ^ pagination.hashCode ^
isLiked.hashCode ^ isLiked.hashCode ^
shareUrl.hashCode ^ shareUrl.hashCode ^
onHeart.hashCode ^ onHeart.hashCode;
error.hashCode;
} }

View File

@ -1,13 +1,11 @@
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});
@ -26,30 +24,12 @@ 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,7 +48,6 @@ 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_albums), placeholder: Text(context.l10n.filter_artist),
), ),
), ),
), ),
@ -121,7 +121,7 @@ class UserAlbumsPage extends HookConsumerWidget {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
Text( Text(
context.l10n.no_favorite_albums_yet, context.l10n.not_following_artists,
textAlign: TextAlign.center, textAlign: TextAlign.center,
).muted().small() ).muted().small()
], ],

View File

@ -8,7 +8,6 @@ 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 {
@ -23,8 +22,6 @@ 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(
@ -36,13 +33,11 @@ class LikedPlaylistPage extends HookConsumerWidget {
collection: playlist, collection: playlist,
image: Assets.images.likedTracks.path, image: Assets.images.likedTracks.path,
pagination: PaginationProps( pagination: PaginationProps(
hasNextPage: likedTracks.asData?.value.hasMore ?? false, hasNextPage: false,
isLoading: likedTracks.isLoadingNextPage && !likedTracks.isLoading, isLoading: likedTracks.isLoading,
onFetchMore: () async { onFetchMore: () {},
await likedTracksNotifier.fetchMore();
},
onFetchAll: () async { onFetchAll: () async {
return await likedTracksNotifier.fetchAll(); return tracks.toList();
}, },
onRefresh: () async { onRefresh: () async {
ref.invalidate(metadataPluginSavedTracksProvider); ref.invalidate(metadataPluginSavedTracksProvider);
@ -51,7 +46,6 @@ 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,7 +79,6 @@ 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/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; import 'package:spotube/provider/metadata_plugin/utils/paginated.dart';
class MetadataPluginSavedAlbumNotifier class MetadataPluginSavedAlbumNotifier
@ -18,50 +18,38 @@ class MetadataPluginSavedAlbumNotifier
@override @override
build() async { build() async {
await ref.watch(metadataPluginAuthenticatedProvider.future); ref.watch(metadataPluginProvider);
return await fetch(0, 20); return await fetch(0, 20);
} }
Future<void> addFavorite(List<SpotubeSimpleAlbumObject> albums) async { Future<void> addFavorite(List<SpotubeSimpleAlbumObject> albums) async {
if (albums.isEmpty || state.value == null) return; await update((state) async {
final oldState = state.value; (await metadataPlugin).album.save(albums.map((e) => e.id).toList());
return state.copyWith(
items: [...state.items, ...albums],
);
});
state = AsyncData( for (final album in albums) {
state.value!.copyWith( ref.invalidate(metadataPluginIsSavedAlbumProvider(album.id));
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 {
if (albums.isEmpty || state.value == null) return; await update((state) async {
final albumIds = albums.map((e) => e.id).toList();
final oldState = state.value; (await metadataPlugin).album.unsave(albumIds);
return state.copyWith(
final albumIds = albums.map((e) => e.id).toList(); items: state.items
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 {
await (await metadataPlugin).album.unsave(albumIds); for (final album in albums) {
} catch (e) { ref.invalidate(metadataPluginIsSavedAlbumProvider(album.id));
state = AsyncData(oldState!);
rethrow;
} }
} }
} }
@ -75,14 +63,9 @@ final metadataPluginSavedAlbumsProvider = AsyncNotifierProvider<
final metadataPluginIsSavedAlbumProvider = final metadataPluginIsSavedAlbumProvider =
FutureProvider.autoDispose.family<bool, String>( FutureProvider.autoDispose.family<bool, String>(
(ref, albumId) async { (ref, albumId) async {
final savedAlbums = final metadataPlugin = await ref.watch(metadataPluginProvider.future);
await ref.watch(metadataPluginSavedAlbumsProvider.future);
final savedAlbumsNotifier =
ref.read(metadataPluginSavedAlbumsProvider.notifier);
final allSavedAlbums = savedAlbums.hasMore
? await savedAlbumsNotifier.fetchAll()
: savedAlbums.items;
return allSavedAlbums.any((element) => element.id == albumId); return metadataPlugin!.user
.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/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/metadata_plugin/utils/paginated.dart'; import 'package:spotube/provider/metadata_plugin/utils/paginated.dart';
class MetadataPluginSavedArtistNotifier class MetadataPluginSavedArtistNotifier
@ -20,53 +20,38 @@ class MetadataPluginSavedArtistNotifier
@override @override
build() async { build() async {
await ref.watch(metadataPluginAuthenticatedProvider.future); ref.watch(metadataPluginProvider);
return await fetch(0, 20); return await fetch(0, 20);
} }
Future<void> addFavorite(List<SpotubeFullArtistObject> artists) async { Future<void> addFavorite(List<SpotubeFullArtistObject> artists) async {
if (artists.isEmpty || state.value == null) return; await update((state) async {
final oldState = state.value; (await metadataPlugin).artist.save(artists.map((e) => e.id).toList());
return state.copyWith(
items: [...state.items, ...artists],
);
});
state = AsyncData( for (final artist in artists) {
state.value!.copyWith( ref.invalidate(metadataPluginIsSavedArtistProvider(artist.id));
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 {
if (artists.isEmpty || state.value == null) return; await update((state) async {
final artistIds = artists.map((e) => e.id).toList();
final oldState = state.value; (await metadataPlugin).artist.unsave(artistIds);
return state.copyWith(
final artistIds = artists.map((e) => e.id).toList(); items: state.items
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(),
), );
); });
try { for (final artist in artists) {
await (await metadataPlugin).artist.unsave(artistIds); ref.invalidate(metadataPluginIsSavedArtistProvider(artist.id));
} catch (e) {
state = AsyncData(oldState!);
rethrow;
} }
} }
} }
@ -80,15 +65,9 @@ final metadataPluginSavedArtistsProvider = AsyncNotifierProvider<
final metadataPluginIsSavedArtistProvider = final metadataPluginIsSavedArtistProvider =
FutureProvider.autoDispose.family<bool, String>( FutureProvider.autoDispose.family<bool, String>(
(ref, artistId) async { (ref, artistId) async {
final savedArtists = final metadataPlugin = await ref.watch(metadataPluginProvider.future);
await ref.watch(metadataPluginSavedArtistsProvider.future);
final savedArtistsNotifier =
ref.read(metadataPluginSavedArtistsProvider.notifier);
final allSavedArtists = savedArtists.hasMore return metadataPlugin!.user
? await savedArtistsNotifier.fetchAll() .isSavedArtists([artistId]).then((value) => value.first);
: savedArtists.items;
return allSavedArtists.any((element) => element.id == artistId);
}, },
); );

View File

@ -1,7 +1,6 @@
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';
@ -22,8 +21,7 @@ class MetadataPluginSavedPlaylistsNotifier
@override @override
build() async { build() async {
await ref.watch(metadataPluginAuthenticatedProvider.future); ref.watch(metadataPluginProvider);
final playlists = await fetch(0, 20); final playlists = await fetch(0, 20);
return playlists; return playlists;
@ -44,43 +42,25 @@ class MetadataPluginSavedPlaylistsNotifier
} }
Future<void> addFavorite(SpotubeSimplePlaylistObject playlist) async { Future<void> addFavorite(SpotubeSimplePlaylistObject playlist) async {
if (state.value == null) return; await update((state) async {
(await metadataPlugin).playlist.save(playlist.id);
return state.copyWith(
items: [...state.items, playlist],
);
});
final oldState = state.value; ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlist.id));
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 {
if (state.value == null) return; await update((state) async {
(await metadataPlugin).playlist.unsave(playlist.id);
return state.copyWith(
items: state.items.where((e) => (e).id != playlist.id).toList(),
);
});
final oldState = state.value; ref.invalidate(metadataPluginIsSavedPlaylistProvider(playlist.id));
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 {
@ -134,16 +114,8 @@ final metadataPluginIsSavedPlaylistProvider =
throw MetadataPluginException.noDefaultPlugin(); throw MetadataPluginException.noDefaultPlugin();
} }
final savedPlaylists = final follows = await plugin.user.isSavedPlaylist(id);
await ref.watch(metadataPluginSavedPlaylistsProvider.future);
final savedPlaylistsNotifier = return follows;
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/core/auth.dart'; import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.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,57 +22,20 @@ class MetadataPluginSavedTracksNotifier
build() async { build() async {
ref.cacheFor(); ref.cacheFor();
await ref.watch(metadataPluginAuthenticatedProvider.future); ref.watch(metadataPluginProvider);
return await fetch(0, 20); return await fetch(0, 20);
} }
Future<void> addFavorite(List<SpotubeTrackObject> tracks) async { Future<void> addFavorite(List<SpotubeTrackObject> tracks) async {
if (state.value == null) { await (await metadataPlugin).track.save(tracks.map((e) => e.id).toList());
return;
}
final oldState = state.value; ref.invalidateSelf();
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 {
if (state.value == null) { await (await metadataPlugin).track.unsave(tracks.map((e) => e.id).toList());
return;
}
final oldState = state.value; ref.invalidateSelf();
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;
}
} }
} }
@ -85,11 +48,9 @@ final metadataPluginSavedTracksProvider = AutoDisposeAsyncNotifierProvider<
final metadataPluginIsSavedTrackProvider = final metadataPluginIsSavedTrackProvider =
FutureProvider.autoDispose.family<bool, String>( FutureProvider.autoDispose.family<bool, String>(
(ref, trackId) async { (ref, trackId) async {
final savedTracks = await ref.watch(metadataPluginSavedTracksProvider.future);
await ref.watch(metadataPluginSavedTracksProvider.future); final allSavedTracks =
final allSavedTracks = savedTracks.hasMore await ref.read(metadataPluginSavedTracksProvider.notifier).fetchAll();
? await ref.read(metadataPluginSavedTracksProvider.notifier).fetchAll()
: savedTracks.items;
return allSavedTracks.any((track) => track.id == trackId); return allSavedTracks.any((track) => track.id == trackId);
}, },

View File

@ -14,16 +14,20 @@ abstract class FamilyPaginatedAsyncNotifier<K, A>
state = AsyncLoadingNext(state.asData!.value); state = AsyncLoadingNext(state.asData!.value);
final newState = await fetch( state = await AsyncValue.guard(
state.value!.nextOffset!, () async {
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>();
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]));
} }
Future<List<K>> fetchAll() async { Future<List<K>> fetchAll() async {

View File

@ -16,16 +16,20 @@ mixin PaginatedAsyncNotifierMixin<K>
state = AsyncLoadingNext(state.asData!.value); state = AsyncLoadingNext(state.asData!.value);
final newState = await fetch( state = await AsyncValue.guard(
state.value!.nextOffset!, () async {
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>();
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]));
} }
Future<List<K>> fetchAll() async { Future<List<K>> fetchAll() async {

View File

@ -48,7 +48,6 @@ 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 {
@ -60,6 +59,35 @@ 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();
@ -74,51 +102,12 @@ class ServerPlaybackRoutes {
"accept-ranges": ["bytes"], "accept-ranges": ["bytes"],
"content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"], "content-range": ["bytes 0-$cachedFileLength/$cachedFileLength"],
}), }),
requestOptions: RequestOptions(path: request.requestedUri.toString()), requestOptions: RequestOptions(path: url),
), ),
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"] ?? "");
@ -134,7 +123,33 @@ class ServerPlaybackRoutes {
); );
} }
final res = await dio.get<Uint8List>(url, options: options); 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 bytes = res.data; final bytes = res.data;
@ -213,11 +228,8 @@ class ServerPlaybackRoutes {
).future, ).future,
); );
final (bytes: audioBytes, response: res) = await streamTrack( final (bytes: audioBytes, response: res) =
request, await streamTrack(sourcedTrack!, request.headers);
sourcedTrack!,
request.headers,
);
return Response( return Response(
res.statusCode!, res.statusCode!,