feat: paginated playlist and album page

This commit is contained in:
Kingkor Roy Tirtho 2023-11-17 13:14:25 +06:00
parent 14069cd4fe
commit 28a5d6bb38
34 changed files with 1376 additions and 1301 deletions

View File

@ -3,29 +3,29 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:spotify/spotify.dart' hide Search; import 'package:spotify/spotify.dart' hide Search;
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/home/home.dart'; import 'package:spotube/pages/home/home.dart';
import 'package:spotube/pages/lastfm_login/lastfm_login.dart'; import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart'; import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart';
import 'package:spotube/pages/lyrics/mini_lyrics.dart'; import 'package:spotube/pages/lyrics/mini_lyrics.dart';
import 'package:spotube/pages/playlist/liked_playlist.dart';
import 'package:spotube/pages/playlist/playlist.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/settings/blacklist.dart'; import 'package:spotube/pages/settings/blacklist.dart';
import 'package:spotube/pages/settings/about.dart'; import 'package:spotube/pages/settings/about.dart';
import 'package:spotube/pages/settings/logs.dart'; import 'package:spotube/pages/settings/logs.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:spotube/components/shared/spotube_page_route.dart'; import 'package:spotube/components/shared/spotube_page_route.dart';
import 'package:spotube/pages/album/album.dart';
import 'package:spotube/pages/artist/artist.dart'; import 'package:spotube/pages/artist/artist.dart';
import 'package:spotube/pages/library/library.dart'; import 'package:spotube/pages/library/library.dart';
import 'package:spotube/pages/desktop_login/login_tutorial.dart'; import 'package:spotube/pages/desktop_login/login_tutorial.dart';
import 'package:spotube/pages/desktop_login/desktop_login.dart'; import 'package:spotube/pages/desktop_login/desktop_login.dart';
import 'package:spotube/pages/lyrics/lyrics.dart'; import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/pages/playlist/playlist.dart';
import 'package:spotube/pages/root/root_app.dart'; import 'package:spotube/pages/root/root_app.dart';
import 'package:spotube/pages/settings/settings.dart'; import 'package:spotube/pages/settings/settings.dart';
import 'package:spotube/pages/mobile_login/mobile_login.dart'; import 'package:spotube/pages/mobile_login/mobile_login.dart';
import '../pages/library/playlist_generate/playlist_generate_result.dart';
final rootNavigatorKey = Catcher2.navigatorKey; final rootNavigatorKey = Catcher2.navigatorKey;
final shellRouteNavigatorKey = GlobalKey<NavigatorState>(); final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
final router = GoRouter( final router = GoRouter(
@ -104,7 +104,9 @@ final router = GoRouter(
path: "/album/:id", path: "/album/:id",
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.extra is AlbumSimple); assert(state.extra is AlbumSimple);
return SpotubePage(child: AlbumPage(state.extra as AlbumSimple)); return SpotubePage(
child: AlbumPage(album: state.extra as AlbumSimple),
);
}, },
), ),
GoRoute( GoRoute(
@ -119,7 +121,9 @@ final router = GoRouter(
pageBuilder: (context, state) { pageBuilder: (context, state) {
assert(state.extra is PlaylistSimple); assert(state.extra is PlaylistSimple);
return SpotubePage( return SpotubePage(
child: PlaylistView(state.extra as PlaylistSimple), child: state.pathParameters["id"] == "user-liked-tracks"
? LikedPlaylistPage(playlist: state.extra as PlaylistSimple)
: PlaylistPage(playlist: state.extra as PlaylistSimple),
); );
}, },
), ),

View File

@ -4,9 +4,12 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/infinite_query.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/queries/album.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -15,7 +18,7 @@ extension FormattedAlbumType on AlbumType {
} }
class AlbumCard extends HookConsumerWidget { class AlbumCard extends HookConsumerWidget {
final Album album; final AlbumSimple album;
const AlbumCard( const AlbumCard(
this.album, { this.album, {
Key? key, Key? key,
@ -27,7 +30,9 @@ class AlbumCard extends HookConsumerWidget {
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final queryClient = useQueryClient(); final queryClient = useQueryClient();
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(
() => playlist.containsCollection(album.id!), () => playlist.containsCollection(album.id!),
[playlist, album.id], [playlist, album.id],
@ -36,6 +41,34 @@ class AlbumCard extends HookConsumerWidget {
final updating = useState(false); final updating = useState(false);
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
Future<List<Track>> fetchAllTrack() async {
if (album.tracks != null && album.tracks!.isNotEmpty) {
return album.tracks!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList();
}
final job = AlbumQueries.tracksOfJob(album.id!);
final query = queryClient.createInfiniteQuery(
job.queryKey,
(page) => job.task(page, (spotify: spotify, album: album)),
initialPage: 0,
nextPage: job.nextPage,
);
return await query.fetchAllTracks(
getAllTracks: () async {
final res = await spotify.albums.tracks(album.id!).all();
return res
.map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
.toList();
},
);
}
return PlaybuttonCard( return PlaybuttonCard(
imageUrl: TypeConversionUtils.image_X_UrlString( imageUrl: TypeConversionUtils.image_X_UrlString(
album.images, album.images,
@ -54,20 +87,15 @@ class AlbumCard extends HookConsumerWidget {
onPlaybuttonPressed: () async { onPlaybuttonPressed: () async {
updating.value = true; updating.value = true;
try { try {
if (isPlaylistPlaying && playing) { if (isPlaylistPlaying) {
return audioPlayer.pause(); return playing ? audioPlayer.pause() : audioPlayer.resume();
} else if (isPlaylistPlaying && !playing) {
return audioPlayer.resume();
} }
await playlistNotifier.load( final fetchedTracks = await fetchAllTrack();
album.tracks
?.map((e) => if (fetchedTracks.isEmpty) return;
TypeConversionUtils.simpleTrack_X_Track(e, album))
.toList() ?? await playlistNotifier.load(fetchedTracks, autoPlay: true);
[],
autoPlay: true,
);
playlistNotifier.addCollection(album.id!); playlistNotifier.addCollection(album.id!);
} finally { } finally {
updating.value = false; updating.value = false;
@ -80,28 +108,16 @@ class AlbumCard extends HookConsumerWidget {
updating.value = true; updating.value = true;
try { try {
final fetchedTracks = final fetchedTracks = await fetchAllTrack();
await queryClient.fetchQuery<List<TrackSimple>, SpotifyApi>(
"album-tracks/${album.id}",
() {
return spotify.albums
.tracks(album.id!)
.all()
.then((value) => value.toList());
},
).then(
(tracks) => tracks
?.map(
(e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
.toList(),
);
if (fetchedTracks == null || fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;
playlistNotifier.addTracks(fetchedTracks); playlistNotifier.addTracks(fetchedTracks);
playlistNotifier.addCollection(album.id!); playlistNotifier.addCollection(album.id!);
if (context.mounted) { if (context.mounted) {
final snackbar = SnackBar( final snackbar = SnackBar(
content: Text("Added ${album.tracks?.length} tracks to queue"), content: Text(
context.l10n.added_to_queue(fetchedTracks.length),
),
action: SnackBarAction( action: SnackBarAction(
label: "Undo", label: "Undo",
onPressed: () { onPressed: () {
@ -110,7 +126,8 @@ class AlbumCard extends HookConsumerWidget {
}, },
), ),
); );
ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar);
scaffoldMessenger?.showSnackBar(snackbar);
} }
} finally { } finally {
updating.value = false; updating.value = false;

View File

@ -18,7 +18,7 @@ import 'package:spotube/components/shared/expandable_search/expandable_search.da
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart'; import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart'; import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
@ -199,7 +199,8 @@ class UserLocalTracks extends HookConsumerWidget {
), ),
const Spacer(), const Spacer(),
ExpandableSearchButton( ExpandableSearchButton(
isFiltering: isFiltering, isFiltering: isFiltering.value,
onPressed: (value) => isFiltering.value = value,
searchFocus: searchFocus, searchFocus: searchFocus,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -222,7 +223,8 @@ class UserLocalTracks extends HookConsumerWidget {
ExpandableSearchField( ExpandableSearchField(
searchController: searchController, searchController: searchController,
searchFocus: searchFocus, searchFocus: searchFocus,
isFiltering: isFiltering, isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
), ),
trackSnapshot.when( trackSnapshot.when(
data: (tracks) { data: (tracks) {
@ -284,7 +286,7 @@ class UserLocalTracks extends HookConsumerWidget {
); );
}, },
loading: () => loading: () =>
const Expanded(child: ShimmerTrackTile(noSliver: true)), const Expanded(child: ShimmerTrackTileGroup(noSliver: true)),
error: (error, stackTrace) => error: (error, stackTrace) =>
Text(error.toString() + stackTrace.toString()), Text(error.toString() + stackTrace.toString()),
) )

View File

@ -11,7 +11,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';

View File

@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/infinite_query.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/spotify_provider.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
@ -23,7 +24,7 @@ class PlaylistCard extends HookConsumerWidget {
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final playing = final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying; useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final queryBowl = QueryClient.of(context); final queryClient = QueryClient.of(context);
final tracks = useState<List<TrackSimple>?>(null); final tracks = useState<List<TrackSimple>?>(null);
bool isPlaylistPlaying = useMemoized( bool isPlaylistPlaying = useMemoized(
() => playlistQueue.containsCollection(playlist.id!), () => playlistQueue.containsCollection(playlist.id!),
@ -34,6 +35,31 @@ class PlaylistCard extends HookConsumerWidget {
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
final me = useQueries.user.me(ref); final me = useQueries.user.me(ref);
Future<List<Track>> fetchAllTracks() async {
if (playlist.id == 'user-liked-tracks') {
return await queryClient.fetchQuery(
"user-liked-tracks",
() => useQueries.playlist.likedTracks(spotify),
) ??
[];
}
final query = queryClient.createInfiniteQuery<List<Track>, dynamic, int>(
"playlist-tracks/${playlist.id}",
(page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!),
initialPage: 0,
nextPage: useQueries.playlist.tracksOfQueryNextPage,
);
return await query.fetchAllTracks(
getAllTracks: () async {
final res =
await spotify.playlists.getTracksByPlaylistId(playlist.id!).all();
return res.toList();
},
);
}
return PlaybuttonCard( return PlaybuttonCard(
margin: const EdgeInsets.symmetric(horizontal: 10), margin: const EdgeInsets.symmetric(horizontal: 10),
title: playlist.name!, title: playlist.name!,
@ -62,18 +88,7 @@ class PlaylistCard extends HookConsumerWidget {
return audioPlayer.resume(); return audioPlayer.resume();
} }
List<Track> fetchedTracks = playlist.id == 'user-liked-tracks' List<Track> fetchedTracks = await fetchAllTracks();
? await queryBowl.fetchQuery(
"user-liked-tracks",
() => useQueries.playlist.likedTracks(spotify, ref),
) ??
[]
: await queryBowl.fetchQuery(
"playlist-tracks/${playlist.id}",
() => useQueries.playlist
.tracksOf(playlist.id!, spotify, ref),
) ??
[];
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;
@ -90,11 +105,8 @@ class PlaylistCard extends HookConsumerWidget {
updating.value = true; updating.value = true;
try { try {
if (isPlaylistPlaying) return; if (isPlaylistPlaying) return;
List<Track> fetchedTracks = await queryBowl.fetchQuery(
"playlist-tracks/${playlist.id}", final fetchedTracks = await fetchAllTracks();
() => useQueries.playlist.tracksOf(playlist.id!, spotify, ref),
) ??
[];
if (fetchedTracks.isEmpty) return; if (fetchedTracks.isEmpty) return;

View File

@ -4,13 +4,15 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
class ExpandableSearchField extends StatelessWidget { class ExpandableSearchField extends StatelessWidget {
final ValueNotifier<bool> isFiltering; final bool isFiltering;
final ValueChanged<bool> onChangeFiltering;
final TextEditingController searchController; final TextEditingController searchController;
final FocusNode searchFocus; final FocusNode searchFocus;
const ExpandableSearchField({ const ExpandableSearchField({
Key? key, Key? key,
required this.isFiltering, required this.isFiltering,
required this.onChangeFiltering,
required this.searchController, required this.searchController,
required this.searchFocus, required this.searchFocus,
}) : super(key: key); }) : super(key: key);
@ -19,17 +21,17 @@ class ExpandableSearchField extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedOpacity( return AnimatedOpacity(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
opacity: isFiltering.value ? 1 : 0, opacity: isFiltering ? 1 : 0,
child: AnimatedSize( child: AnimatedSize(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
child: SizedBox( child: SizedBox(
height: isFiltering.value ? 50 : 0, height: isFiltering ? 50 : 0,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: CallbackShortcuts( child: CallbackShortcuts(
bindings: { bindings: {
LogicalKeySet(LogicalKeyboardKey.escape): () { LogicalKeySet(LogicalKeyboardKey.escape): () {
isFiltering.value = false; onChangeFiltering(false);
searchController.clear(); searchController.clear();
searchFocus.unfocus(); searchFocus.unfocus();
} }
@ -52,7 +54,7 @@ class ExpandableSearchField extends StatelessWidget {
} }
class ExpandableSearchButton extends StatelessWidget { class ExpandableSearchButton extends StatelessWidget {
final ValueNotifier<bool> isFiltering; final bool isFiltering;
final FocusNode searchFocus; final FocusNode searchFocus;
final Widget icon; final Widget icon;
final ValueChanged<bool>? onPressed; final ValueChanged<bool>? onPressed;
@ -73,18 +75,17 @@ class ExpandableSearchButton extends StatelessWidget {
icon: icon, icon: icon,
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: backgroundColor:
isFiltering.value ? theme.colorScheme.secondaryContainer : null, isFiltering ? theme.colorScheme.secondaryContainer : null,
foregroundColor: isFiltering.value ? theme.colorScheme.secondary : null, foregroundColor: isFiltering ? theme.colorScheme.secondary : null,
minimumSize: const Size(25, 25), minimumSize: const Size(25, 25),
), ),
onPressed: () { onPressed: () {
isFiltering.value = !isFiltering.value; if (isFiltering) {
if (isFiltering.value) {
searchFocus.requestFocus(); searchFocus.requestFocus();
} else { } else {
searchFocus.unfocus(); searchFocus.unfocus();
} }
onPressed?.call(isFiltering.value); onPressed?.call(!isFiltering);
}, },
); );
} }

View File

@ -50,7 +50,7 @@ class ShimmerArtistProfile extends HookWidget {
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
const Flexible(child: ShimmerTrackTile(noSliver: true)), const Flexible(child: ShimmerTrackTileGroup(noSliver: true)),
], ],
); );
} }

View File

@ -70,8 +70,7 @@ class ShimmerTrackTilePainter extends CustomPainter {
} }
class ShimmerTrackTile extends StatelessWidget { class ShimmerTrackTile extends StatelessWidget {
final bool noSliver; const ShimmerTrackTile({super.key});
const ShimmerTrackTile({super.key, this.noSliver = false});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -82,10 +81,6 @@ class ShimmerTrackTile extends StatelessWidget {
shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300], shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300],
); );
if (noSliver) {
return ListView.builder(
itemCount: 5,
itemBuilder: (context, index) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8), padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8),
child: CustomPaint( child: CustomPaint(
@ -97,24 +92,31 @@ class ShimmerTrackTile extends StatelessWidget {
), ),
), ),
); );
}, }
}
class ShimmerTrackTileGroup extends StatelessWidget {
final bool noSliver;
final int count;
const ShimmerTrackTileGroup({
super.key,
this.noSliver = false,
this.count = 5,
});
@override
Widget build(BuildContext context) {
if (noSliver) {
return ListView.builder(
itemCount: 5,
itemBuilder: (context, index) => const ShimmerTrackTile(),
); );
} }
return SliverList( return SliverList(
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => Padding( (BuildContext context, int index) => const ShimmerTrackTile(),
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8), childCount: count,
child: CustomPaint(
size: const Size(double.infinity, 60),
painter: ShimmerTrackTilePainter(
background: shimmerTheme.shimmerBackgroundColor ??
theme.scaffoldBackgroundColor,
foreground: shimmerTheme.shimmerColor ?? theme.cardColor,
),
),
),
childCount: 5,
), ),
); );
} }

View File

@ -1,229 +0,0 @@
import 'dart:ui';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/album/album_card.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
enum PlayButtonState {
playing,
notPlaying,
loading,
}
class TrackCollectionHeading<T> extends HookConsumerWidget {
final String title;
final String? description;
final String titleImage;
final List<Widget> buttons;
final AlbumSimple? album;
final Query<List<TrackSimple>, T> tracksSnapshot;
final PlayButtonState playingState;
final void Function([Track? currentTrack]) onPlay;
final void Function([Track? currentTrack]) onShuffledPlay;
final PaletteColor? color;
const TrackCollectionHeading({
Key? key,
required this.title,
required this.titleImage,
required this.buttons,
required this.tracksSnapshot,
required this.playingState,
required this.onPlay,
required this.onShuffledPlay,
required this.color,
this.description,
this.album,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final cleanDescription = useDescription(description);
return LayoutBuilder(
builder: (context, constrains) {
return DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(titleImage),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black45,
theme.colorScheme.surface,
],
begin: const FractionalOffset(0, 0),
end: const FractionalOffset(0, 1),
tileMode: TileMode.clamp,
),
),
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: SafeArea(
child: Flex(
direction: constrains.mdAndDown
? Axis.vertical
: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: titleImage,
placeholder: Assets.albumPlaceholder.path,
),
),
),
const SizedBox(width: 10, height: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constrains.mdAndDown ? 400 : 300,
),
child: AutoSizeText(
title,
style: theme.textTheme.titleLarge!.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
maxLines: 2,
minFontSize: 16,
overflow: TextOverflow.ellipsis,
),
),
if (album != null)
Text(
"${album?.albumType?.formatted}${context.l10n.released}${DateTime.tryParse(
album?.releaseDate ?? "",
)?.year}",
style: theme.textTheme.titleMedium!.copyWith(
color: Colors.white,
fontWeight: FontWeight.normal,
),
),
if (cleanDescription != null)
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constrains.mdAndDown ? 400 : 300,
),
child: AutoSizeText(
cleanDescription,
style: const TextStyle(color: Colors.white),
maxLines: 2,
overflow: TextOverflow.fade,
minFontSize: 14,
),
),
const SizedBox(height: 10),
IconTheme(
data: theme.iconTheme.copyWith(
color: Colors.white,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: buttons,
),
),
const SizedBox(height: 10),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constrains.mdAndDown ? 400 : 300,
),
child: Row(
mainAxisSize: constrains.smAndUp
? MainAxisSize.min
: MainAxisSize.min,
children: [
Expanded(
child: FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
label: Text(context.l10n.shuffle),
icon: const Icon(SpotubeIcons.shuffle),
onPressed: tracksSnapshot.data == null ||
playingState ==
PlayButtonState.playing
? null
: onShuffledPlay,
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: color?.color,
foregroundColor: color?.bodyTextColor,
),
onPressed: tracksSnapshot.data != null ||
playingState ==
PlayButtonState.loading
? onPlay
: null,
icon: switch (playingState) {
PlayButtonState.playing =>
const Icon(SpotubeIcons.pause),
PlayButtonState.notPlaying =>
const Icon(SpotubeIcons.play),
PlayButtonState.loading =>
const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: .7,
),
),
},
label: Text(
playingState == PlayButtonState.playing
? context.l10n.stop
: context.l10n.play,
),
),
),
],
),
),
],
)
],
),
),
),
),
),
),
);
},
);
}
}

View File

@ -1,274 +0,0 @@
import 'dart:async';
import 'package:fl_query/fl_query.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/utils/use_custom_status_bar_color.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart';
import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class TrackCollectionView<T> extends HookConsumerWidget {
final logger = getLogger(TrackCollectionView);
final String id;
final String title;
final String? description;
final Query<List<TrackSimple>, T> tracksSnapshot;
final String titleImage;
final PlayButtonState playingState;
final Future<void> Function([Track? currentTrack]) onPlay;
final void Function([Track? currentTrack]) onShuffledPlay;
final void Function() onAddToQueue;
final void Function() onShare;
final Widget? heartBtn;
final AlbumSimple? album;
final bool showShare;
final bool isOwned;
final bool bottomSpace;
final String routePath;
TrackCollectionView({
required this.title,
required this.id,
required this.tracksSnapshot,
required this.titleImage,
required this.playingState,
required this.onPlay,
required this.onShuffledPlay,
required this.onAddToQueue,
required this.onShare,
required this.routePath,
this.heartBtn,
this.album,
this.description,
this.showShare = true,
this.isOwned = false,
this.bottomSpace = false,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final auth = ref.watch(AuthenticationNotifier.provider);
final color = usePaletteGenerator(titleImage).dominantColor;
final List<Widget> buttons = [
if (showShare)
IconButton(
icon: const Icon(SpotubeIcons.share),
onPressed: onShare,
),
if (isOwned)
IconButton(
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(playlistId: id);
},
);
},
),
if (heartBtn != null && auth != null) heartBtn!,
IconButton(
onPressed: playingState == PlayButtonState.playing
? null
: tracksSnapshot.data != null
? onAddToQueue
: null,
icon: const Icon(
SpotubeIcons.queueAdd,
),
),
];
final controller = useScrollController();
final collapsed = useState(false);
useCustomStatusBarColor(
Colors.transparent,
GoRouterState.of(context).matchedLocation == routePath,
);
useEffect(() {
listener() {
if (controller.position.pixels >= 390 && !collapsed.value) {
collapsed.value = true;
} else if (controller.position.pixels < 390 && collapsed.value) {
collapsed.value = false;
}
}
controller.addListener(listener);
return () => controller.removeListener(listener);
}, [collapsed.value]);
return Scaffold(
appBar: kIsDesktop
? const PageWindowTitleBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
leadingWidth: 400,
leading: Align(
alignment: Alignment.centerLeft,
child: BackButton(color: Colors.white),
),
)
: null,
extendBodyBehindAppBar: kIsDesktop,
body: RefreshIndicator(
onRefresh: () async {
await tracksSnapshot.refresh();
},
child: InterScrollbar(
controller: controller,
child: CustomScrollView(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverAppBar(
actions: [
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: collapsed.value ? 1 : 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: buttons,
),
),
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: collapsed.value ? 1 : 0,
child: IconButton(
tooltip: context.l10n.shuffle,
icon: const Icon(SpotubeIcons.shuffle),
onPressed: playingState == PlayButtonState.playing
? null
: onShuffledPlay,
),
),
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: collapsed.value ? 1 : 0,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
backgroundColor: theme.colorScheme.inversePrimary,
),
onPressed: tracksSnapshot.data != null ? onPlay : null,
child: switch (playingState) {
PlayButtonState.playing =>
const Icon(SpotubeIcons.pause),
PlayButtonState.notPlaying =>
const Icon(SpotubeIcons.play),
PlayButtonState.loading => const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: .7,
),
),
},
),
),
],
floating: false,
pinned: true,
expandedHeight: 400,
automaticallyImplyLeading: kIsMobile,
leading:
kIsMobile ? const BackButton(color: Colors.white) : null,
iconTheme: IconThemeData(color: color?.titleTextColor),
primary: true,
backgroundColor: color?.color.withOpacity(.8),
title: collapsed.value
? Text(
title,
style: theme.textTheme.titleMedium!.copyWith(
color: color?.titleTextColor,
fontWeight: FontWeight.w600,
),
)
: null,
centerTitle: true,
flexibleSpace: FlexibleSpaceBar(
background: TrackCollectionHeading<T>(
color: color,
title: title,
description: description,
titleImage: titleImage,
playingState: playingState,
onPlay: onPlay,
onShuffledPlay: onShuffledPlay,
tracksSnapshot: tracksSnapshot,
buttons: buttons,
album: album,
),
),
),
HookBuilder(
builder: (context) {
if (tracksSnapshot.isLoading || !tracksSnapshot.hasData) {
return const ShimmerTrackTile();
} else if (tracksSnapshot.hasError) {
return SliverToBoxAdapter(
child: Text(
context.l10n.error(tracksSnapshot.error ?? ""),
),
);
}
return TracksTableView(
(tracksSnapshot.data ?? []).map(
(track) {
if (track is Track) {
return track;
} else {
return TypeConversionUtils.simpleTrack_X_Track(
track,
album!,
);
}
},
).toList(),
onTrackPlayButtonPressed: onPlay,
playlistId: id,
userPlaylist: isOwned,
onFiltering: () {
// scroll the flexible space
// to allow more space for search results
controller.animateTo(
330,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
},
);
},
)
],
),
),
));
}
}

View File

@ -1,368 +0,0 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/utils/service_utils.dart';
final trackCollectionSortState =
StateProvider.family<SortBy, String>((ref, _) => SortBy.none);
class TracksTableView extends HookConsumerWidget {
final Future<void> Function(Track currentTrack)? onTrackPlayButtonPressed;
final List<Track> tracks;
final bool userPlaylist;
final String? playlistId;
final bool isSliver;
final Widget? heading;
final VoidCallback? onFiltering;
const TracksTableView(
this.tracks, {
Key? key,
this.onTrackPlayButtonPressed,
this.onFiltering,
this.userPlaylist = false,
this.playlistId,
this.heading,
this.isSliver = true,
}) : super(key: key);
@override
Widget build(context, ref) {
final mediaQuery = MediaQuery.of(context);
ref.watch(ProxyPlaylistNotifier.provider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final apiType =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
const tableHeadStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
final selected = useState<List<String>>([]);
final showCheck = useState<bool>(false);
final sortBy = ref.watch(trackCollectionSortState(playlistId ?? ''));
final isFiltering = useState<bool>(false);
final searchController = useTextEditingController();
final searchFocus = useFocusNode();
final controller = useScrollController();
// this will trigger update on each change in searchController
useValueListenable(searchController);
final filteredTracks = useMemoized(() {
if (searchController.text.isEmpty) {
return tracks;
}
return tracks
.map((e) => (weightedRatio(e.name!, searchController.text), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
}, [tracks, searchController.text]);
final sortedTracks = useMemoized(
() {
return ServiceUtils.sortTracks(filteredTracks, sortBy);
},
[filteredTracks, sortBy],
);
final selectedTracks = useMemoized(
() => sortedTracks.where(
(track) => selected.value.contains(track.id),
),
[sortedTracks],
);
final children = tracks.isEmpty
? [const NotFound(vertical: true)]
: [
if (heading != null) heading!,
LayoutBuilder(builder: (context, constrains) {
return Row(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation,
child: child,
),
);
},
child: showCheck.value
? Checkbox(
value: selected.value.length == sortedTracks.length,
onChanged: (checked) {
if (!showCheck.value) showCheck.value = true;
if (checked == true) {
selected.value =
sortedTracks.map((s) => s.id!).toList();
} else {
selected.value = [];
showCheck.value = false;
}
},
)
: constrains.mdAndUp
? const SizedBox(width: 32)
: const SizedBox(width: 16),
),
Expanded(
flex: 7,
child: Row(
children: [
Text(
context.l10n.title,
style: tableHeadStyle,
overflow: TextOverflow.ellipsis,
),
],
),
),
// used alignment of this table-head
if (constrains.mdAndUp)
Expanded(
flex: 3,
child: Row(
children: [
Text(
context.l10n.album,
overflow: TextOverflow.ellipsis,
style: tableHeadStyle,
),
],
),
),
SortTracksDropdown(
value: sortBy,
onChanged: (value) {
ref
.read(trackCollectionSortState(playlistId ?? '')
.notifier)
.state = value;
},
),
ExpandableSearchButton(
isFiltering: isFiltering,
searchFocus: searchFocus,
onPressed: (value) {
if (isFiltering.value) {
onFiltering?.call();
}
},
),
AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
Text(
context.l10n.more_actions,
style: tableHeadStyle,
),
],
onSelected: (action) async {
switch (action) {
case "download":
{
final confirmed = apiType == AudioSource.piped ||
await showDialog(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
);
if (confirmed != true) return;
await downloader
.batchAddToQueue(selectedTracks.toList());
if (context.mounted) {
selected.value = [];
showCheck.value = false;
}
break;
}
case "add-to-playlist":
{
if (context.mounted) {
await showDialog(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
tracks: selectedTracks.toList(),
);
},
);
}
break;
}
case "play-next":
{
playback.addTracksAtFirst(selectedTracks);
if (playlistId != null) {
playback.addCollection(playlistId!);
}
selected.value = [];
showCheck.value = false;
break;
}
case "add-to-queue":
{
playback.addTracks(selectedTracks);
if (playlistId != null) {
playback.addCollection(playlistId!);
}
selected.value = [];
showCheck.value = false;
break;
}
default:
}
},
icon: const Icon(SpotubeIcons.moreVertical),
children: [
PopSheetEntry(
value: "download",
leading: const Icon(SpotubeIcons.download),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.download_count(selectedTracks.length),
),
),
if (!userPlaylist)
PopSheetEntry(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n
.add_count_to_playlist(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(
context.l10n
.add_count_to_queue(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
title: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
),
const SizedBox(width: 10),
],
);
}),
ExpandableSearchField(
isFiltering: isFiltering,
searchController: searchController,
searchFocus: searchFocus,
),
...sortedTracks.mapIndexed((i, track) {
return TrackTile(
index: i,
track: track,
selected: selected.value.contains(track.id),
userPlaylist: userPlaylist,
playlistId: playlistId,
onTap: () async {
if (showCheck.value) {
final alreadyChecked = selected.value.contains(track.id);
if (alreadyChecked) {
selected.value =
selected.value.where((id) => id != track.id).toList();
} else {
selected.value = [...selected.value, track.id!];
}
} else {
final isBlackListed = ref.read(
BlackListNotifier.provider.select(
(blacklist) => blacklist.contains(
BlacklistedElement.track(track.id!, track.name!),
),
),
);
if (isBlackListed) return;
await onTrackPlayButtonPressed?.call(track);
}
},
onLongPress: () {
if (showCheck.value) return;
showCheck.value = true;
selected.value = [...selected.value, track.id!];
},
onChanged: !showCheck.value
? null
: (value) {
if (value == null) return;
if (value) {
selected.value = [...selected.value, track.id!];
} else {
selected.value = selected.value
.where((id) => id != track.id)
.toList();
}
},
);
}),
// extra space for mobile devices where keyboard takes half of the screen
if (isFiltering.value)
SizedBox(
height: mediaQuery.size.height * .75, //75% of the screen
),
];
if (isSliver) {
return SliverSafeArea(
top: false,
sliver: SliverList(
delegate: SliverChildListDelegate(children),
),
);
}
return SafeArea(
child: ListView(
controller: controller,
children: children,
),
);
}
}

View File

@ -9,7 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/hover_builder.dart'; import 'package:spotube/components/shared/hover_builder.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/components/shared/track_table/track_options.dart'; import 'package:spotube/components/shared/track_tile/track_options.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/local_track.dart';

View File

@ -0,0 +1,124 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class TrackViewBodySection extends HookConsumerWidget {
const TrackViewBodySection({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));
final searchController = useTextEditingController();
final searchFocus = useFocusNode();
useValueListenable(searchController);
final searchQuery = searchController.text;
final isFiltering = useState(false);
final tracks = useMemoized(() {
List<Track> filteredTracks;
if (searchQuery.isEmpty) {
filteredTracks = props.tracks;
} else {
filteredTracks = props.tracks
.map((e) => (weightedRatio(e.name!, searchQuery), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
}
return ServiceUtils.sortTracks(filteredTracks, trackViewState.sortBy);
}, [trackViewState.sortBy, searchQuery, props.tracks]);
final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId);
final isActive = playlist.collections.contains(props.collectionId);
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: TrackViewBodyHeaders(
isFiltering: isFiltering,
searchFocus: searchFocus,
),
),
const SliverGap(8),
SliverToBoxAdapter(
child: ExpandableSearchField(
isFiltering: isFiltering.value,
onChangeFiltering: (value) {
isFiltering.value = value;
},
searchController: searchController,
searchFocus: searchFocus,
),
),
SliverSafeArea(
top: false,
sliver: SliverInfiniteList(
itemCount: tracks.length,
onFetchData: props.pagination.onFetchMore,
isLoading: props.pagination.isLoading,
hasReachedMax: !props.pagination.hasNextPage,
loadingBuilder: (context) => const ShimmerTrackTile(),
itemBuilder: (context, index) {
final track = tracks[index];
return TrackTile(
track: track,
index: index,
selected: trackViewState.selectedTrackIds.contains(track.id!),
playlistId: props.collectionId,
userPlaylist: isUserPlaylist,
onChanged: !trackViewState.isSelecting
? null
: (value) {
trackViewState.toggleTrackSelection(track.id!);
},
onLongPress: () {
trackViewState.selectTrack(track.id!);
},
onTap: () async {
if (trackViewState.isSelecting) {
trackViewState.toggleTrackSelection(track.id!);
return;
}
if (isActive || playlist.tracks.contains(track)) {
await playlistNotifier.jumpToTrack(track);
} else {
await playlistNotifier.load(
props.tracks,
initialIndex: index,
autoPlay: true,
);
playlistNotifier.addCollection(props.collectionId);
}
},
);
},
),
),
],
);
}
}

View File

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_options.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
class TrackViewBodyHeaders extends HookConsumerWidget {
final ValueNotifier<bool> isFiltering;
final FocusNode searchFocus;
const TrackViewBodyHeaders({
Key? key,
required this.isFiltering,
required this.searchFocus,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));
return LayoutBuilder(
builder: (context, constrains) {
return Row(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation,
child: child,
),
);
},
child: trackViewState.isSelecting
? Checkbox(
value: trackViewState.hasSelectedAll,
onChanged: (checked) {
if (checked == true) {
trackViewState.selectAll();
} else {
trackViewState.deselectAll();
}
},
)
: constrains.mdAndUp
? const SizedBox(width: 32)
: const SizedBox(width: 16),
),
Expanded(
flex: 7,
child: Row(
children: [
Text(
context.l10n.title,
style: textTheme.bodyLarge,
overflow: TextOverflow.ellipsis,
),
],
),
),
// used alignment of this table-head
if (constrains.mdAndUp)
Expanded(
flex: 3,
child: Row(
children: [
Text(
context.l10n.album,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyLarge,
),
],
),
),
SortTracksDropdown(
value: trackViewState.sortBy,
onChanged: (value) {
trackViewState.sort(value);
},
),
ExpandableSearchButton(
isFiltering: isFiltering.value,
searchFocus: searchFocus,
onPressed: (value) {
isFiltering.value = value;
if (value) {
searchFocus.requestFocus();
} else {
searchFocus.unfocus();
}
},
),
const TrackViewBodyOptions(),
],
);
},
);
}
}

View File

@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_state.dart';
import 'package:spotube/services/queries/queries.dart';
class TrackViewBodyOptions extends HookConsumerWidget {
const TrackViewBodyOptions({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final ThemeData(:textTheme) = Theme.of(context);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
final trackViewState = ref.watch(trackViewProvider(props.tracks));
final selectedTracks = trackViewState.selectedTracks;
final userPlaylists = useQueries.playlist.ofMineAll(ref);
final isUserPlaylist =
userPlaylists.data?.any((e) => e.id == props.collectionId) ?? false;
return AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
Text(
context.l10n.more_actions,
style: textTheme.bodyLarge,
),
],
onSelected: (action) async {
switch (action) {
case "download":
{
final confirmed = audioSource == AudioSource.piped ||
await showDialog(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
);
if (confirmed != true) return;
await downloader.batchAddToQueue(selectedTracks);
trackViewState.deselectAll();
break;
}
case "add-to-playlist":
{
if (context.mounted) {
await showDialog(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
tracks: selectedTracks.toList(),
);
},
);
}
break;
}
case "play-next":
{
playlistNotifier.addTracksAtFirst(selectedTracks);
playlistNotifier.addCollection(props.collectionId);
trackViewState.deselectAll();
break;
}
case "add-to-queue":
{
playlistNotifier.addTracks(selectedTracks);
playlistNotifier.addCollection(props.collectionId);
trackViewState.deselectAll();
break;
}
default:
}
},
icon: const Icon(SpotubeIcons.moreVertical),
children: [
PopSheetEntry(
value: "download",
leading: const Icon(SpotubeIcons.download),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.download_count(selectedTracks.length),
),
),
if (!isUserPlaylist)
PopSheetEntry(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.add_count_to_playlist(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(
context.l10n.add_count_to_queue(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
title: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
);
}
}

View File

@ -0,0 +1,18 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/services/queries/queries.dart';
bool useIsUserPlaylist(WidgetRef ref, String playlistId) {
final userPlaylistsQuery = useQueries.playlist.ofMineAll(ref);
final me = useQueries.user.me(ref);
return useMemoized(
() =>
userPlaylistsQuery.data?.any((e) =>
e.id == playlistId &&
me.data != null &&
e.owner?.id == me.data?.id) ??
false,
[userPlaylistsQuery.data, playlistId, me.data],
);
}

View File

@ -0,0 +1,142 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/header_actions.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/header_buttons.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:gap/gap.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart';
class TrackViewFlexHeader extends HookConsumerWidget {
const TrackViewFlexHeader({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final ThemeData(:colorScheme, :textTheme, :iconTheme) = Theme.of(context);
final defaultTextStyle = DefaultTextStyle.of(context);
final mediaQuery = MediaQuery.of(context);
final description = useDescription(props.description);
final palette = usePaletteColor(props.image, ref);
return IconTheme(
data: iconTheme.copyWith(color: palette.bodyTextColor),
child: SliverLayoutBuilder(
builder: (context, constrains) {
final isExpanded = constrains.scrollOffset < 350;
final headingStyle = (mediaQuery.mdAndDown
? textTheme.headlineSmall
: textTheme.headlineMedium)
?.copyWith(
color: palette.bodyTextColor,
);
return SliverAppBar(
iconTheme: iconTheme.copyWith(
color: palette.bodyTextColor,
size: 16,
),
actions: isExpanded
? []
: [
const TrackViewHeaderActions(),
TrackViewHeaderButtons(compact: true, color: palette),
],
floating: false,
pinned: true,
expandedHeight: 400,
automaticallyImplyLeading: false,
backgroundColor: palette.color,
title: isExpanded ? null : Text(props.title, style: headingStyle),
flexibleSpace: FlexibleSpaceBar(
background: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(props.image),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black45,
colorScheme.surface,
],
begin: const FractionalOffset(0, 0),
end: const FractionalOffset(0, 1),
tileMode: TileMode.clamp,
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flex(
direction: mediaQuery.mdAndDown
? Axis.vertical
: Axis.horizontal,
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: props.image,
width: 200,
height: 200,
placeholder: Assets.albumPlaceholder.path,
),
),
const Gap(20),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: mediaQuery.mdAndDown
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
Text(props.title, style: headingStyle),
const SizedBox(height: 10),
if (description != null)
Text(
description,
style: defaultTextStyle.style.copyWith(
color: palette.bodyTextColor,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Gap(10),
const TrackViewHeaderActions(),
const Gap(10),
TrackViewHeaderButtons(color: palette),
],
),
],
),
],
),
),
),
),
),
),
);
},
),
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
class TrackViewHeaderActions extends HookConsumerWidget {
const TrackViewHeaderActions({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final isActive = playlist.collections.contains(props.collectionId);
final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final auth = ref.watch(AuthenticationNotifier.provider);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: context.l10n.share,
icon: const Icon(SpotubeIcons.share),
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: props.shareUrl),
);
scaffoldMessenger.showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
"Copied ${props.shareUrl} to clipboard",
textAlign: TextAlign.center,
),
),
);
},
),
IconButton(
icon: const Icon(SpotubeIcons.queueAdd),
tooltip: context.l10n.add_to_queue,
onPressed: isActive || props.tracks.isEmpty
? null
: () async {
final tracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(tracks);
playlistNotifier.addCollection(props.collectionId);
},
),
if (props.onHeart != null && auth != null)
HeartButton(
isLiked: props.isLiked,
icon: isUserPlaylist ? SpotubeIcons.trash : null,
tooltip: props.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
onPressed: () {
props.onHeart?.call();
if (isUserPlaylist) {
context.pop();
}
},
),
],
);
}
}

View File

@ -0,0 +1,137 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
class TrackViewHeaderButtons extends HookConsumerWidget {
final PaletteColor color;
final bool compact;
const TrackViewHeaderButtons({
Key? key,
required this.color,
this.compact = false,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final isActive = playlist.collections.contains(props.collectionId);
final isLoading = useState(false);
const progressIndicator = Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: .8),
),
);
void onShuffle() async {
try {
isLoading.value = true;
final allTracks = await props.pagination.onFetchAll();
await playlistNotifier.load(
allTracks,
autoPlay: true,
initialIndex: Random().nextInt(allTracks.length),
);
await audioPlayer.setShuffle(true);
playlistNotifier.addCollection(props.collectionId);
} finally {
isLoading.value = false;
}
}
void onPlay() async {
try {
isLoading.value = true;
final allTracks = await props.pagination.onFetchAll();
await playlistNotifier.load(allTracks, autoPlay: true);
playlistNotifier.addCollection(props.collectionId);
} finally {
isLoading.value = false;
}
}
if (compact) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isActive && !isLoading.value)
IconButton(
icon: const Icon(SpotubeIcons.shuffle),
onPressed: props.tracks.isEmpty ? null : onShuffle,
),
const Gap(10),
IconButton.filledTonal(
icon: isActive
? const Icon(SpotubeIcons.pause)
: isLoading.value
? progressIndicator
: const Icon(SpotubeIcons.play),
onPressed: isActive || props.tracks.isEmpty || isLoading.value
? null
: onPlay,
),
const Gap(10),
],
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: isActive || isLoading.value ? 0 : 1,
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: SizedBox.square(
dimension: isActive || isLoading.value ? 0 : null,
child: FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
label: Text(context.l10n.shuffle),
icon: const Icon(SpotubeIcons.shuffle),
onPressed: props.tracks.isEmpty ? null : onShuffle,
),
),
),
),
const Gap(10),
FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: color.color,
foregroundColor: color.bodyTextColor,
),
onPressed: isActive || props.tracks.isEmpty || isLoading.value
? null
: onPlay,
icon: isActive
? const Icon(SpotubeIcons.pause)
: isLoading.value
? progressIndicator
: const Icon(SpotubeIcons.play),
label: Text(context.l10n.play),
),
],
);
}
}

View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/tracks_view/sections/header/flexible_header.dart';
import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
class TrackView extends HookConsumerWidget {
const TrackView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
return Scaffold(
appBar: DesktopTools.platform.isDesktop
? const PageWindowTitleBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
leadingWidth: 400,
leading: Align(
alignment: Alignment.centerLeft,
child: BackButton(color: Colors.white),
),
)
: null,
extendBodyBehindAppBar: true,
body: CustomScrollView(
slivers: [
const TrackViewFlexHeader(),
SliverAnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: props.tracks.isEmpty
? const ShimmerTrackTileGroup()
: const TrackViewBodySection(),
),
],
),
);
}
}

View File

@ -0,0 +1,102 @@
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart' hide Page;
import 'package:spotify/spotify.dart';
class PaginationProps {
final bool hasNextPage;
final bool isLoading;
final VoidCallback onFetchMore;
final Future<List<Track>> Function() onFetchAll;
const PaginationProps({
required this.hasNextPage,
required this.isLoading,
required this.onFetchMore,
required this.onFetchAll,
});
factory PaginationProps.fromQuery(
InfiniteQuery<List<Track>, dynamic, int> query, {
required Future<List<Track>> Function() onFetchAll,
}) {
return PaginationProps(
hasNextPage: query.hasNextPage,
isLoading: query.isLoadingNextPage,
onFetchMore: query.fetchNext,
onFetchAll: onFetchAll,
);
}
@override
operator ==(Object other) {
return other is PaginationProps &&
other.hasNextPage == hasNextPage &&
other.isLoading == isLoading &&
other.onFetchMore == onFetchMore &&
other.onFetchAll == onFetchAll;
}
@override
int get hashCode =>
super.hashCode ^
hasNextPage.hashCode ^
isLoading.hashCode ^
onFetchMore.hashCode ^
onFetchAll.hashCode;
}
class InheritedTrackView extends InheritedWidget {
final String collectionId;
final String title;
final String? description;
final String image;
final String routePath;
final List<Track> tracks;
final PaginationProps pagination;
final bool isLiked;
final String shareUrl;
// events
final VoidCallback? onHeart; // if null heart button will hidden
const InheritedTrackView({
super.key,
required super.child,
required this.collectionId,
required this.title,
this.description,
required this.image,
required this.tracks,
required this.pagination,
required this.routePath,
required this.shareUrl,
this.isLiked = false,
this.onHeart,
});
@override
bool updateShouldNotify(InheritedTrackView oldWidget) {
return oldWidget.title != title ||
oldWidget.description != description ||
oldWidget.image != image ||
oldWidget.tracks != tracks ||
oldWidget.pagination != pagination ||
oldWidget.isLiked != isLiked ||
oldWidget.onHeart != onHeart ||
oldWidget.shareUrl != shareUrl ||
oldWidget.routePath != routePath ||
oldWidget.collectionId != collectionId ||
oldWidget.child != child;
}
static InheritedTrackView of(BuildContext context) {
final widget =
context.dependOnInheritedWidgetOfExactType<InheritedTrackView>();
if (widget == null) {
throw Exception(
'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]',
);
}
return widget;
}
}

View File

@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
class TrackViewNotifier extends ChangeNotifier {
List<Track> tracks;
List<String> selectedTrackIds;
SortBy sortBy;
String? searchQuery;
TrackViewNotifier(
this.tracks, {
this.selectedTrackIds = const [],
this.sortBy = SortBy.none,
this.searchQuery,
});
bool get isSelecting => selectedTrackIds.isNotEmpty;
bool get hasSelectedAll =>
selectedTrackIds.length == tracks.length && tracks.isNotEmpty;
List<Track> get selectedTracks =>
tracks.where((e) => selectedTrackIds.contains(e.id)).toList();
void selectTrack(String trackId) {
selectedTrackIds = [...selectedTrackIds, trackId];
notifyListeners();
}
void unselectTrack(String trackId) {
selectedTrackIds = selectedTrackIds.where((e) => e != trackId).toList();
notifyListeners();
}
void toggleTrackSelection(String trackId) {
if (selectedTrackIds.contains(trackId)) {
unselectTrack(trackId);
} else {
selectTrack(trackId);
}
}
void selectAll() {
selectedTrackIds = tracks.map((e) => e.id!).toList();
notifyListeners();
}
void deselectAll() {
selectedTrackIds = [];
notifyListeners();
}
void sort(SortBy sortBy) {
this.sortBy = sortBy;
notifyListeners();
}
}
final trackViewProvider = ChangeNotifierProvider.autoDispose
.family<TrackViewNotifier, List<Track>>((ref, tracks) {
return TrackViewNotifier(tracks);
});

View File

@ -0,0 +1,30 @@
import 'package:fl_query/fl_query.dart';
import 'package:spotify/spotify.dart';
extension FetchAllTracks on InfiniteQuery<List<Track>, dynamic, int> {
Future<List<Track>> fetchAllTracks({
required Future<List<Track>> Function() getAllTracks,
}) async {
if (!hasNextPage) {
return pages.expand((page) => page).toList();
}
final tracks = await getAllTracks();
final pagedTracks = tracks.fold(
<int, List<Track>>{},
(acc, element) {
final index = acc.length;
final groupIndex = index ~/ 20;
final group = acc[groupIndex] ?? [];
group.add(element);
acc[groupIndex] = group;
return acc;
},
);
for (final group in pagedTracks.entries) {
setPageData(group.key, group.value);
}
return tracks.toList();
}
}

View File

@ -1,157 +1,79 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; import 'package:spotube/extensions/infinite_query.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class AlbumPage extends HookConsumerWidget { class AlbumPage extends HookConsumerWidget {
final AlbumSimple album; final AlbumSimple album;
const AlbumPage(this.album, {Key? key}) : super(key: key); const AlbumPage({
Key? key,
Future<void> playPlaylist( required this.album,
List<Track> tracks, }) : super(key: key);
WidgetRef ref, {
Track? currentTrack,
}) async {
final playlist = ref.read(ProxyPlaylistNotifier.provider);
final playback = ref.read(ProxyPlaylistNotifier.notifier);
final sortBy = ref.read(trackCollectionSortState(album.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isAlbumPlaying = playlist.containsTracks(tracks);
if (!isAlbumPlaying) {
playback.addCollection(album.id!); // for enabling loading indicator
await playback.load(
sortedTracks,
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
);
playback.addCollection(album.id!);
} else if (isAlbumPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final spotify = ref.watch(spotifyProvider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier); final tracksQuery = useQueries.album.tracksOf(ref, album);
final tracksSnapshot = useQueries.album.tracksOf(ref, album.id!); final tracks = useMemoized(() {
return tracksQuery.pages.expand((element) => element).toList();
}, [tracksQuery.pages]);
final albumArt = useMemoized( final client = useQueryClient();
() => TypeConversionUtils.image_X_UrlString(
final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!);
final isLiked = albumIsSaved.data ?? false;
final toggleAlbumLike = useMutations.album.toggleFavorite(
ref,
album.id!,
refreshQueries: [albumIsSaved.key],
onData: (_, __) async {
await client.refreshInfiniteQueryAllPages("current-user-albums");
},
);
return InheritedTrackView(
collectionId: album.id!,
image: TypeConversionUtils.image_X_UrlString(
album.images, album.images,
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
), ),
[album.images]);
final mediaQuery = MediaQuery.of(context);
final isAlbumPlaying = useMemoized(
() => playlist.collections.contains(album.id!),
[playlist, album],
);
final albumTrackPlaying = useMemoized(
() =>
tracksSnapshot.data?.any((s) => s.id! == playlist.activeTrack?.id!) ==
true &&
playlist.activeTrack is SourcedTrack,
[playlist.activeTrack, tracksSnapshot.data],
);
return TrackCollectionView(
id: album.id!,
playingState: isAlbumPlaying && albumTrackPlaying
? PlayButtonState.playing
: isAlbumPlaying && !albumTrackPlaying
? PlayButtonState.loading
: PlayButtonState.notPlaying,
title: album.name!, title: album.name!,
titleImage: albumArt, description:
tracksSnapshot: tracksSnapshot, "${context.l10n.released}${album.releaseDate}${album.artists!.first.name}",
album: album, tracks: tracks,
pagination: PaginationProps.fromQuery(
tracksQuery,
onFetchAll: () {
return tracksQuery.fetchAllTracks(getAllTracks: () async {
final res = await spotify.albums.tracks(album.id!).all();
return res
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList();
});
},
),
routePath: "/album/${album.id}", routePath: "/album/${album.id}",
bottomSpace: mediaQuery.mdAndDown, shareUrl: album.externalUrls!.spotify!,
onPlay: ([track]) async { isLiked: isLiked,
if (tracksSnapshot.hasData) { onHeart: albumIsSaved.hasData
if (!isAlbumPlaying) { ? () {
await playPlaylist( toggleAlbumLike.mutate(isLiked);
tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(),
ref,
);
} else if (isAlbumPlaying && track != null) {
await playPlaylist(
tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(),
currentTrack: track,
ref,
);
} else {
await playback
.removeTracks(tracksSnapshot.data!.map((track) => track.id!));
} }
} : null,
}, child: const TrackView(),
onAddToQueue: () {
if (tracksSnapshot.hasData && !isAlbumPlaying) {
playback.addTracks(
tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(),
);
playback.addCollection(album.id!);
}
},
onShare: () {
Clipboard.setData(
ClipboardData(text: "https://open.spotify.com/album/${album.id}"),
);
},
heartBtn: AlbumHeartButton(album: album),
onShuffledPlay: ([track]) {
// Shuffle the tracks (create a copy of playlist)
if (tracksSnapshot.hasData) {
final tracks = tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList()
..shuffle();
if (!isAlbumPlaying) {
playPlaylist(
tracks,
ref,
);
} else if (isAlbumPlaying && track != null) {
playPlaylist(
tracks,
ref,
currentTrack: track,
);
} else {
// TODO: Disable ability to stop playback from playlist/album
// playback.stop();
}
}
},
); );
} }
} }

View File

@ -10,7 +10,7 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart'; import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/artist/artist_album_list.dart'; import 'package:spotube/components/artist/artist_album_list.dart';
import 'package:spotube/components/artist/artist_card.dart'; import 'package:spotube/components/artist/artist_card.dart';

View File

@ -70,7 +70,8 @@ class GenrePage extends HookConsumerWidget {
child: Column( child: Column(
children: [ children: [
ExpandableSearchField( ExpandableSearchField(
isFiltering: isFiltering, isFiltering: isFiltering.value,
onChangeFiltering: (value) => isFiltering.value = value,
searchController: searchController, searchController: searchController,
searchFocus: searchFocus, searchFocus: searchFocus,
), ),
@ -103,10 +104,11 @@ class GenrePage extends HookConsumerWidget {
top: 0, top: 0,
right: 10, right: 10,
child: ExpandableSearchButton( child: ExpandableSearchButton(
isFiltering: isFiltering, isFiltering: isFiltering.value,
searchFocus: searchFocus, searchFocus: searchFocus,
icon: const Icon(SpotubeIcons.search), icon: const Icon(SpotubeIcons.search),
onPressed: (value) { onPressed: (value) {
isFiltering.value = value;
if (isFiltering.value) { if (isFiltering.value) {
scrollController.animateTo( scrollController.animateTo(
0, 0,

View File

@ -0,0 +1,45 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/tracks_view/track_view.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class LikedPlaylistPage extends HookConsumerWidget {
final PlaylistSimple playlist;
const LikedPlaylistPage({
Key? key,
required this.playlist,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final likedTracks = useQueries.playlist.likedTracksQuery(ref);
final tracks = likedTracks.data ?? <Track>[];
return InheritedTrackView(
collectionId: playlist.id!,
image: TypeConversionUtils.image_X_UrlString(
playlist.images,
placeholder: ImagePlaceholder.collection,
),
pagination: PaginationProps(
hasNextPage: false,
isLoading: false,
onFetchMore: () {},
onFetchAll: () async {
return tracks.toList();
},
),
title: playlist.name!,
description: playlist.description,
tracks: tracks,
routePath: '/playlist/${playlist.id}',
isLiked: false,
shareUrl: "",
onHeart: null,
child: const TrackView(),
);
}
}

View File

@ -1,178 +1,82 @@
import 'package:flutter/services.dart'; import 'package:flutter/material.dart' hide Page;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/components/shared/tracks_view/track_view.dart';
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/infinite_query.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistView extends HookConsumerWidget { class PlaylistPage extends HookConsumerWidget {
final logger = getLogger(PlaylistView); final PlaylistSimple playlist;
final PlaylistSimple playlistSimple; const PlaylistPage({
PlaylistView(this.playlistSimple, {Key? key}) : super(key: key); Key? key,
required this.playlist,
}) : super(key: key);
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final proxyPlaylist = ref.watch(ProxyPlaylistNotifier.provider); final spotify = ref.watch(spotifyProvider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final tracksQuery = useQueries.playlist.tracksOfQuery(ref, playlist.id!);
final mediaQuery = MediaQuery.of(context); final tracks = useMemoized(
() {
final meSnapshot = useQueries.user.me(ref); return tracksQuery.pages.expand((page) => page).toList();
},
final playlistQuery = useQueries.playlist.byId(ref, playlistSimple.id!); [tracksQuery.pages],
final playlist = playlistQuery.data ?? playlistSimple;
final playlistTrackSnapshot =
useQueries.playlist.tracksOfQuery(ref, playlist.id!);
final likedTracksSnapshot = useQueries.playlist.likedTracksQuery(ref);
final tracksSnapshot = playlist.id! == "user-liked-tracks"
? likedTracksSnapshot
: playlistTrackSnapshot;
final isPlaylistPlaying = useMemoized(
() => proxyPlaylist.collections.contains(playlist.id!),
[proxyPlaylist, playlist],
); );
final titleImage = useMemoized( final me = useQueries.user.me(ref);
() => TypeConversionUtils.image_X_UrlString(
final isLikedQuery = useQueries.playlist.doesUserFollow(
ref,
playlist.id!,
me.data?.id ?? '',
);
final togglePlaylistLike = useMutations.playlist.toggleFavorite(
ref,
playlist.id!,
refreshQueries: [
isLikedQuery.key,
],
);
return InheritedTrackView(
collectionId: playlist.id!,
image: TypeConversionUtils.image_X_UrlString(
playlist.images, playlist.images,
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
), ),
[playlist.images]); pagination: PaginationProps.fromQuery(
tracksQuery,
final playlistTrackPlaying = useMemoized( onFetchAll: () async {
() => return tracksQuery.fetchAllTracks(
tracksSnapshot.data getAllTracks: () async {
?.any((s) => s.id! == proxyPlaylist.activeTrack?.id!) == final res = await spotify.playlists
true && .getTracksByPlaylistId(playlist.id!)
proxyPlaylist.activeTrack is SourcedTrack, .all();
[proxyPlaylist.activeTrack, tracksSnapshot.data], return res.toList();
},
); );
},
final playPlaylist = useCallback(( ),
List<Track> tracks,
WidgetRef ref, {
Track? currentTrack,
}) async {
final playback = ref.read(ProxyPlaylistNotifier.notifier);
final sortBy = ref.read(trackCollectionSortState(playlist.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks);
if (!isPlaylistPlaying) {
playback.addCollection(playlist.id!); // for enabling loading indicator
await playback.load(
sortedTracks,
initialIndex:
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
playback.addCollection(playlist.id!);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != proxyPlaylist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}, [proxyPlaylist, playlist]);
final ownPlaylist =
playlist.owner?.id != null && playlist.owner?.id == meSnapshot.data?.id;
return TrackCollectionView(
id: playlist.id!,
playingState: isPlaylistPlaying && playlistTrackPlaying
? PlayButtonState.playing
: isPlaylistPlaying && !playlistTrackPlaying
? PlayButtonState.loading
: PlayButtonState.notPlaying,
title: playlist.name!, title: playlist.name!,
titleImage: titleImage,
tracksSnapshot: tracksSnapshot,
description: playlist.description, description: playlist.description,
isOwned: ownPlaylist, tracks: tracks,
onPlay: ([track]) async { routePath: '/playlist/${playlist.id}',
if (tracksSnapshot.hasData) { isLiked: isLikedQuery.data ?? false,
if (!isPlaylistPlaying || (isPlaylistPlaying && track != null)) { shareUrl: playlist.externalUrls?.spotify ?? "",
await playPlaylist( onHeart: () async {
tracksSnapshot.data!, if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) {
ref, return;
currentTrack: track,
);
} else {
await playlistNotifier
.removeTracks(tracksSnapshot.data!.map((e) => e.id!));
}
}
},
onAddToQueue: () {
if (tracksSnapshot.hasData && !isPlaylistPlaying) {
playlistNotifier.addTracks(tracksSnapshot.data!);
playlistNotifier.addCollection(playlist.id!);
}
},
bottomSpace: mediaQuery.mdAndDown,
showShare: playlist.id != "user-liked-tracks",
routePath: "/playlist/${playlist.id}",
onShare: () {
final data = "https://open.spotify.com/playlist/${playlist.id}";
Clipboard.setData(
ClipboardData(text: data),
).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
"Copied $data to clipboard",
textAlign: TextAlign.center,
),
),
);
});
},
heartBtn: PlaylistHeartButton(
playlist: playlist,
icon: ownPlaylist ? SpotubeIcons.trash : null,
onData: (data) {
GoRouter.of(context).pop();
},
),
onShuffledPlay: ([track]) {
final tracks = [...?tracksSnapshot.data]..shuffle();
if (tracksSnapshot.hasData) {
if (!isPlaylistPlaying) {
playPlaylist(
tracks,
ref,
currentTrack: track,
);
} else if (isPlaylistPlaying && track != null) {
playPlaylist(
tracks,
ref,
currentTrack: track,
);
} else {
// TODO: Remove the ability to stop the playlist
// playlistNotifier.stop();
}
} }
await togglePlaylistLike.mutate(isLikedQuery.data!);
}, },
child: const TrackView(),
); );
} }
} }

View File

@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';

View File

@ -1,10 +1,13 @@
import 'package:catcher_2/catcher_2.dart'; import 'package:catcher_2/catcher_2.dart';
import 'package:fl_query/fl_query.dart'; import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart'; import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart';
import 'package:spotube/hooks/spotify/use_spotify_query.dart'; import 'package:spotube/hooks/spotify/use_spotify_query.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class AlbumQueries { class AlbumQueries {
const AlbumQueries(); const AlbumQueries();
@ -27,19 +30,42 @@ class AlbumQueries {
); );
} }
Query<List<TrackSimple>, dynamic> tracksOf( static final tracksOfJob = InfiniteQueryJob.withVariableKey<
WidgetRef ref, List<Track>,
String albumId, dynamic,
) { int,
return useSpotifyQuery<List<TrackSimple>, dynamic>( ({
"album-tracks/$albumId", SpotifyApi spotify,
(spotify) { AlbumSimple album,
return spotify.albums })>(
.getTracks(albumId) baseQueryKey: "album-tracks",
.all() initialPage: 0,
.then((value) => value.toList()); task: (albumId, page, args) async {
final res =
await args!.spotify.albums.tracks(albumId).getPage(20, page * 20);
return res.items
?.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, args.album))
.toList() ??
<Track>[];
}, },
ref: ref, nextPage: (lastPage, lastPageData) {
if (lastPageData.length < 20) {
return null;
}
return lastPage + 1;
},
);
InfiniteQuery<List<Track>, dynamic, int> tracksOf(
WidgetRef ref,
AlbumSimple album,
) {
final spotify = ref.watch(spotifyProvider);
return useInfiniteQueryJob(
job: tracksOfJob(album.id!),
args: (spotify: spotify, album: album),
); );
} }

View File

@ -166,17 +166,14 @@ class PlaylistQueries {
); );
} }
Future<List<Track>> likedTracks( Future<List<Track>> likedTracks(SpotifyApi spotify) async {
SpotifyApi spotify,
WidgetRef ref,
) async {
final tracks = await spotify.tracks.me.saved.all(); final tracks = await spotify.tracks.me.saved.all();
return tracks.map((e) => e.track!).toList(); return tracks.map((e) => e.track!).toList();
} }
Query<List<Track>, dynamic> likedTracksQuery(WidgetRef ref) { Query<List<Track>, dynamic> likedTracksQuery(WidgetRef ref) {
final query = useCallback((spotify) => likedTracks(spotify, ref), []); final query = useCallback((spotify) => likedTracks(spotify), []);
final context = useContext(); final context = useContext();
return useSpotifyQuery<List<Track>, dynamic>( return useSpotifyQuery<List<Track>, dynamic>(
@ -201,28 +198,6 @@ class PlaylistQueries {
); );
} }
Future<List<Track>> tracksOf(
String playlistId,
SpotifyApi spotify,
WidgetRef ref,
) async {
if (playlistId == "user-liked-tracks") return <Track>[];
return spotify.playlists.getTracksByPlaylistId(playlistId).all().then(
(value) => value.where((track) => track.id != null).toList(),
);
}
Query<List<Track>, dynamic> tracksOfQuery(
WidgetRef ref,
String playlistId,
) {
return useSpotifyQuery<List<Track>, dynamic>(
"playlist-tracks/$playlistId",
(spotify) => tracksOf(playlistId, spotify, ref),
ref: ref,
);
}
Query<Playlist, dynamic> byId(WidgetRef ref, String id) { Query<Playlist, dynamic> byId(WidgetRef ref, String id) {
return useSpotifyQuery<Playlist, dynamic>( return useSpotifyQuery<Playlist, dynamic>(
"playlist/$id", "playlist/$id",
@ -233,6 +208,42 @@ class PlaylistQueries {
); );
} }
Future<List<Track>> tracksOf(
int pageParam,
SpotifyApi spotify,
String playlistId,
) async {
try {
final playlists = await spotify.playlists
.getTracksByPlaylistId(playlistId)
.getPage(20, pageParam * 20);
return playlists.items?.toList() ?? <Track>[];
} catch (e, stack) {
Catcher2.reportCheckedError(e, stack);
rethrow;
}
}
int? tracksOfQueryNextPage(int lastPage, List<Track> lastPageData) {
if (lastPageData.length < 20) {
return null;
}
return lastPage + 1;
}
InfiniteQuery<List<Track>, dynamic, int> tracksOfQuery(
WidgetRef ref,
String playlistId,
) {
return useSpotifyInfiniteQuery<List<Track>, dynamic, int>(
"playlist-tracks/$playlistId",
(page, spotify) => tracksOf(page, spotify, playlistId),
initialPage: 0,
nextPage: tracksOfQueryNextPage,
ref: ref,
);
}
InfiniteQuery<Page<PlaylistSimple>, dynamic, int> featured( InfiniteQuery<Page<PlaylistSimple>, dynamic, int> featured(
WidgetRef ref, WidgetRef ref,
) { ) {

View File

@ -969,6 +969,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.6" version: "1.1.6"
gap:
dependency: "direct main"
description:
name: gap
sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d
url: "https://pub.dev"
source: hosted
version: "3.0.1"
glob: glob:
dependency: transitive dependency: transitive
description: description:
@ -1861,6 +1869,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.99" version: "0.0.99"
sliver_tools:
dependency: "direct main"
description:
name: sliver_tools
sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6
url: "https://pub.dev"
source: hosted
version: "0.2.12"
smtc_windows: smtc_windows:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -114,6 +114,8 @@ dependencies:
url: https://github.com/thielepaul/flutter-draggable-scrollbar.git url: https://github.com/thielepaul/flutter-draggable-scrollbar.git
ref: cfd570035bf393de541d32e9b28808b5d7e602df ref: cfd570035bf393de541d32e9b28808b5d7e602df
very_good_infinite_list: ^0.7.1 very_good_infinite_list: ^0.7.1
gap: ^3.0.1
sliver_tools: ^0.2.12
dev_dependencies: dev_dependencies:
build_runner: ^2.3.2 build_runner: ^2.3.2