mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
feat: paginated playlist and album page
This commit is contained in:
parent
14069cd4fe
commit
28a5d6bb38
@ -3,29 +3,29 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
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/lastfm_login/lastfm_login.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/playlist/liked_playlist.dart';
|
||||
import 'package:spotube/pages/playlist/playlist.dart';
|
||||
import 'package:spotube/pages/search/search.dart';
|
||||
import 'package:spotube/pages/settings/blacklist.dart';
|
||||
import 'package:spotube/pages/settings/about.dart';
|
||||
import 'package:spotube/pages/settings/logs.dart';
|
||||
import 'package:spotube/utils/platform.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/library/library.dart';
|
||||
import 'package:spotube/pages/desktop_login/login_tutorial.dart';
|
||||
import 'package:spotube/pages/desktop_login/desktop_login.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/settings/settings.dart';
|
||||
import 'package:spotube/pages/mobile_login/mobile_login.dart';
|
||||
|
||||
import '../pages/library/playlist_generate/playlist_generate_result.dart';
|
||||
|
||||
final rootNavigatorKey = Catcher2.navigatorKey;
|
||||
final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
|
||||
final router = GoRouter(
|
||||
@ -104,7 +104,9 @@ final router = GoRouter(
|
||||
path: "/album/:id",
|
||||
pageBuilder: (context, state) {
|
||||
assert(state.extra is AlbumSimple);
|
||||
return SpotubePage(child: AlbumPage(state.extra as AlbumSimple));
|
||||
return SpotubePage(
|
||||
child: AlbumPage(album: state.extra as AlbumSimple),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
@ -119,7 +121,9 @@ final router = GoRouter(
|
||||
pageBuilder: (context, state) {
|
||||
assert(state.extra is PlaylistSimple);
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -4,9 +4,12 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.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/spotify_provider.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/type_conversion_utils.dart';
|
||||
|
||||
@ -15,7 +18,7 @@ extension FormattedAlbumType on AlbumType {
|
||||
}
|
||||
|
||||
class AlbumCard extends HookConsumerWidget {
|
||||
final Album album;
|
||||
final AlbumSimple album;
|
||||
const AlbumCard(
|
||||
this.album, {
|
||||
Key? key,
|
||||
@ -27,7 +30,9 @@ class AlbumCard extends HookConsumerWidget {
|
||||
final playing =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
|
||||
final queryClient = useQueryClient();
|
||||
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
() => playlist.containsCollection(album.id!),
|
||||
[playlist, album.id],
|
||||
@ -36,6 +41,34 @@ class AlbumCard extends HookConsumerWidget {
|
||||
final updating = useState(false);
|
||||
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(
|
||||
imageUrl: TypeConversionUtils.image_X_UrlString(
|
||||
album.images,
|
||||
@ -54,20 +87,15 @@ class AlbumCard extends HookConsumerWidget {
|
||||
onPlaybuttonPressed: () async {
|
||||
updating.value = true;
|
||||
try {
|
||||
if (isPlaylistPlaying && playing) {
|
||||
return audioPlayer.pause();
|
||||
} else if (isPlaylistPlaying && !playing) {
|
||||
return audioPlayer.resume();
|
||||
if (isPlaylistPlaying) {
|
||||
return playing ? audioPlayer.pause() : audioPlayer.resume();
|
||||
}
|
||||
|
||||
await playlistNotifier.load(
|
||||
album.tracks
|
||||
?.map((e) =>
|
||||
TypeConversionUtils.simpleTrack_X_Track(e, album))
|
||||
.toList() ??
|
||||
[],
|
||||
autoPlay: true,
|
||||
);
|
||||
final fetchedTracks = await fetchAllTrack();
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
} finally {
|
||||
updating.value = false;
|
||||
@ -80,28 +108,16 @@ class AlbumCard extends HookConsumerWidget {
|
||||
|
||||
updating.value = true;
|
||||
try {
|
||||
final fetchedTracks =
|
||||
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(),
|
||||
);
|
||||
final fetchedTracks = await fetchAllTrack();
|
||||
|
||||
if (fetchedTracks == null || fetchedTracks.isEmpty) return;
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
playlistNotifier.addTracks(fetchedTracks);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
if (context.mounted) {
|
||||
final snackbar = SnackBar(
|
||||
content: Text("Added ${album.tracks?.length} tracks to queue"),
|
||||
content: Text(
|
||||
context.l10n.added_to_queue(fetchedTracks.length),
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: "Undo",
|
||||
onPressed: () {
|
||||
@ -110,7 +126,8 @@ class AlbumCard extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar);
|
||||
|
||||
scaffoldMessenger?.showSnackBar(snackbar);
|
||||
}
|
||||
} finally {
|
||||
updating.value = false;
|
||||
|
@ -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/shimmers/shimmer_track_tile.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/models/local_track.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
@ -199,7 +199,8 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
),
|
||||
const Spacer(),
|
||||
ExpandableSearchButton(
|
||||
isFiltering: isFiltering,
|
||||
isFiltering: isFiltering.value,
|
||||
onPressed: (value) => isFiltering.value = value,
|
||||
searchFocus: searchFocus,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
@ -222,7 +223,8 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
ExpandableSearchField(
|
||||
searchController: searchController,
|
||||
searchFocus: searchFocus,
|
||||
isFiltering: isFiltering,
|
||||
isFiltering: isFiltering.value,
|
||||
onChangeFiltering: (value) => isFiltering.value = value,
|
||||
),
|
||||
trackSnapshot.when(
|
||||
data: (tracks) {
|
||||
@ -284,7 +286,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
);
|
||||
},
|
||||
loading: () =>
|
||||
const Expanded(child: ShimmerTrackTile(noSliver: true)),
|
||||
const Expanded(child: ShimmerTrackTileGroup(noSliver: true)),
|
||||
error: (error, stackTrace) =>
|
||||
Text(error.toString() + stackTrace.toString()),
|
||||
)
|
||||
|
@ -11,7 +11,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
|
||||
import 'package:spotube/collections/spotube_icons.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/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/context.dart';
|
||||
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
|
||||
|
@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.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/spotify_provider.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 playing =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
final queryBowl = QueryClient.of(context);
|
||||
final queryClient = QueryClient.of(context);
|
||||
final tracks = useState<List<TrackSimple>?>(null);
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
() => playlistQueue.containsCollection(playlist.id!),
|
||||
@ -34,6 +35,31 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
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(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||
title: playlist.name!,
|
||||
@ -62,18 +88,7 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
return audioPlayer.resume();
|
||||
}
|
||||
|
||||
List<Track> fetchedTracks = playlist.id == 'user-liked-tracks'
|
||||
? 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),
|
||||
) ??
|
||||
[];
|
||||
List<Track> fetchedTracks = await fetchAllTracks();
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
@ -90,11 +105,8 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
updating.value = true;
|
||||
try {
|
||||
if (isPlaylistPlaying) return;
|
||||
List<Track> fetchedTracks = await queryBowl.fetchQuery(
|
||||
"playlist-tracks/${playlist.id}",
|
||||
() => useQueries.playlist.tracksOf(playlist.id!, spotify, ref),
|
||||
) ??
|
||||
[];
|
||||
|
||||
final fetchedTracks = await fetchAllTracks();
|
||||
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
|
@ -4,13 +4,15 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
|
||||
class ExpandableSearchField extends StatelessWidget {
|
||||
final ValueNotifier<bool> isFiltering;
|
||||
final bool isFiltering;
|
||||
final ValueChanged<bool> onChangeFiltering;
|
||||
final TextEditingController searchController;
|
||||
final FocusNode searchFocus;
|
||||
|
||||
const ExpandableSearchField({
|
||||
Key? key,
|
||||
required this.isFiltering,
|
||||
required this.onChangeFiltering,
|
||||
required this.searchController,
|
||||
required this.searchFocus,
|
||||
}) : super(key: key);
|
||||
@ -19,17 +21,17 @@ class ExpandableSearchField extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: isFiltering.value ? 1 : 0,
|
||||
opacity: isFiltering ? 1 : 0,
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: SizedBox(
|
||||
height: isFiltering.value ? 50 : 0,
|
||||
height: isFiltering ? 50 : 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: CallbackShortcuts(
|
||||
bindings: {
|
||||
LogicalKeySet(LogicalKeyboardKey.escape): () {
|
||||
isFiltering.value = false;
|
||||
onChangeFiltering(false);
|
||||
searchController.clear();
|
||||
searchFocus.unfocus();
|
||||
}
|
||||
@ -52,7 +54,7 @@ class ExpandableSearchField extends StatelessWidget {
|
||||
}
|
||||
|
||||
class ExpandableSearchButton extends StatelessWidget {
|
||||
final ValueNotifier<bool> isFiltering;
|
||||
final bool isFiltering;
|
||||
final FocusNode searchFocus;
|
||||
final Widget icon;
|
||||
final ValueChanged<bool>? onPressed;
|
||||
@ -73,18 +75,17 @@ class ExpandableSearchButton extends StatelessWidget {
|
||||
icon: icon,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor:
|
||||
isFiltering.value ? theme.colorScheme.secondaryContainer : null,
|
||||
foregroundColor: isFiltering.value ? theme.colorScheme.secondary : null,
|
||||
isFiltering ? theme.colorScheme.secondaryContainer : null,
|
||||
foregroundColor: isFiltering ? theme.colorScheme.secondary : null,
|
||||
minimumSize: const Size(25, 25),
|
||||
),
|
||||
onPressed: () {
|
||||
isFiltering.value = !isFiltering.value;
|
||||
if (isFiltering.value) {
|
||||
if (isFiltering) {
|
||||
searchFocus.requestFocus();
|
||||
} else {
|
||||
searchFocus.unfocus();
|
||||
}
|
||||
onPressed?.call(isFiltering.value);
|
||||
onPressed?.call(!isFiltering);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ class ShimmerArtistProfile extends HookWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Flexible(child: ShimmerTrackTile(noSliver: true)),
|
||||
const Flexible(child: ShimmerTrackTileGroup(noSliver: true)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -70,8 +70,7 @@ class ShimmerTrackTilePainter extends CustomPainter {
|
||||
}
|
||||
|
||||
class ShimmerTrackTile extends StatelessWidget {
|
||||
final bool noSliver;
|
||||
const ShimmerTrackTile({super.key, this.noSliver = false});
|
||||
const ShimmerTrackTile({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -82,10 +81,6 @@ class ShimmerTrackTile extends StatelessWidget {
|
||||
shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300],
|
||||
);
|
||||
|
||||
if (noSliver) {
|
||||
return ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8),
|
||||
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(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8),
|
||||
child: CustomPaint(
|
||||
size: const Size(double.infinity, 60),
|
||||
painter: ShimmerTrackTilePainter(
|
||||
background: shimmerTheme.shimmerBackgroundColor ??
|
||||
theme.scaffoldBackgroundColor,
|
||||
foreground: shimmerTheme.shimmerColor ?? theme.cardColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
childCount: 5,
|
||||
(BuildContext context, int index) => const ShimmerTrackTile(),
|
||||
childCount: count,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -9,7 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/hover_builder.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.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/duration.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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],
|
||||
);
|
||||
}
|
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
44
lib/components/shared/tracks_view/track_view.dart
Normal file
44
lib/components/shared/tracks_view/track_view.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
102
lib/components/shared/tracks_view/track_view_props.dart
Normal file
102
lib/components/shared/tracks_view/track_view_props.dart
Normal 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;
|
||||
}
|
||||
}
|
64
lib/components/shared/tracks_view/track_view_provider.dart
Normal file
64
lib/components/shared/tracks_view/track_view_provider.dart
Normal 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);
|
||||
});
|
30
lib/extensions/infinite_query.dart
Normal file
30
lib/extensions/infinite_query.dart
Normal 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();
|
||||
}
|
||||
}
|
@ -1,157 +1,79 @@
|
||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.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/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/context.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/sourced_track/sourced_track.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class AlbumPage extends HookConsumerWidget {
|
||||
final AlbumSimple album;
|
||||
const AlbumPage(this.album, {Key? key}) : super(key: key);
|
||||
|
||||
Future<void> playPlaylist(
|
||||
List<Track> tracks,
|
||||
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);
|
||||
}
|
||||
}
|
||||
const AlbumPage({
|
||||
Key? key,
|
||||
required this.album,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
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(
|
||||
() => TypeConversionUtils.image_X_UrlString(
|
||||
final client = useQueryClient();
|
||||
|
||||
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,
|
||||
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!,
|
||||
titleImage: albumArt,
|
||||
tracksSnapshot: tracksSnapshot,
|
||||
album: album,
|
||||
description:
|
||||
"${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}",
|
||||
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}",
|
||||
bottomSpace: mediaQuery.mdAndDown,
|
||||
onPlay: ([track]) async {
|
||||
if (tracksSnapshot.hasData) {
|
||||
if (!isAlbumPlaying) {
|
||||
await playPlaylist(
|
||||
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!));
|
||||
shareUrl: album.externalUrls!.spotify!,
|
||||
isLiked: isLiked,
|
||||
onHeart: albumIsSaved.hasData
|
||||
? () {
|
||||
toggleAlbumLike.mutate(isLiked);
|
||||
}
|
||||
}
|
||||
},
|
||||
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();
|
||||
}
|
||||
}
|
||||
},
|
||||
: null,
|
||||
child: const TrackView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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/shimmers/shimmer_artist_profile.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/artist/artist_album_list.dart';
|
||||
import 'package:spotube/components/artist/artist_card.dart';
|
||||
|
@ -70,7 +70,8 @@ class GenrePage extends HookConsumerWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
ExpandableSearchField(
|
||||
isFiltering: isFiltering,
|
||||
isFiltering: isFiltering.value,
|
||||
onChangeFiltering: (value) => isFiltering.value = value,
|
||||
searchController: searchController,
|
||||
searchFocus: searchFocus,
|
||||
),
|
||||
@ -103,10 +104,11 @@ class GenrePage extends HookConsumerWidget {
|
||||
top: 0,
|
||||
right: 10,
|
||||
child: ExpandableSearchButton(
|
||||
isFiltering: isFiltering,
|
||||
isFiltering: isFiltering.value,
|
||||
searchFocus: searchFocus,
|
||||
icon: const Icon(SpotubeIcons.search),
|
||||
onPressed: (value) {
|
||||
isFiltering.value = value;
|
||||
if (isFiltering.value) {
|
||||
scrollController.animateTo(
|
||||
0,
|
||||
|
45
lib/pages/playlist/liked_playlist.dart
Normal file
45
lib/pages/playlist/liked_playlist.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
@ -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: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/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: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/sourced_track/sourced_track.dart';
|
||||
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlaylistView extends HookConsumerWidget {
|
||||
final logger = getLogger(PlaylistView);
|
||||
final PlaylistSimple playlistSimple;
|
||||
PlaylistView(this.playlistSimple, {Key? key}) : super(key: key);
|
||||
class PlaylistPage extends HookConsumerWidget {
|
||||
final PlaylistSimple playlist;
|
||||
const PlaylistPage({
|
||||
Key? key,
|
||||
required this.playlist,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final proxyPlaylist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
final tracksQuery = useQueries.playlist.tracksOfQuery(ref, playlist.id!);
|
||||
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
final meSnapshot = useQueries.user.me(ref);
|
||||
|
||||
final playlistQuery = useQueries.playlist.byId(ref, playlistSimple.id!);
|
||||
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 tracks = useMemoized(
|
||||
() {
|
||||
return tracksQuery.pages.expand((page) => page).toList();
|
||||
},
|
||||
[tracksQuery.pages],
|
||||
);
|
||||
|
||||
final titleImage = useMemoized(
|
||||
() => TypeConversionUtils.image_X_UrlString(
|
||||
final me = useQueries.user.me(ref);
|
||||
|
||||
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,
|
||||
placeholder: ImagePlaceholder.collection,
|
||||
),
|
||||
[playlist.images]);
|
||||
|
||||
final playlistTrackPlaying = useMemoized(
|
||||
() =>
|
||||
tracksSnapshot.data
|
||||
?.any((s) => s.id! == proxyPlaylist.activeTrack?.id!) ==
|
||||
true &&
|
||||
proxyPlaylist.activeTrack is SourcedTrack,
|
||||
[proxyPlaylist.activeTrack, tracksSnapshot.data],
|
||||
pagination: PaginationProps.fromQuery(
|
||||
tracksQuery,
|
||||
onFetchAll: () async {
|
||||
return tracksQuery.fetchAllTracks(
|
||||
getAllTracks: () async {
|
||||
final res = await spotify.playlists
|
||||
.getTracksByPlaylistId(playlist.id!)
|
||||
.all();
|
||||
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!,
|
||||
titleImage: titleImage,
|
||||
tracksSnapshot: tracksSnapshot,
|
||||
description: playlist.description,
|
||||
isOwned: ownPlaylist,
|
||||
onPlay: ([track]) async {
|
||||
if (tracksSnapshot.hasData) {
|
||||
if (!isPlaylistPlaying || (isPlaylistPlaying && track != null)) {
|
||||
await playPlaylist(
|
||||
tracksSnapshot.data!,
|
||||
ref,
|
||||
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();
|
||||
}
|
||||
tracks: tracks,
|
||||
routePath: '/playlist/${playlist.id}',
|
||||
isLiked: isLikedQuery.data ?? false,
|
||||
shareUrl: playlist.externalUrls?.spotify ?? "",
|
||||
onHeart: () async {
|
||||
if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) {
|
||||
return;
|
||||
}
|
||||
await togglePlaylistLike.mutate(isLikedQuery.data!);
|
||||
},
|
||||
child: const TrackView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.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/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
|
||||
|
@ -1,10 +1,13 @@
|
||||
import 'package:catcher_2/catcher_2.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:spotify/spotify.dart';
|
||||
import 'package:spotube/hooks/spotify/use_spotify_infinite_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/utils/type_conversion_utils.dart';
|
||||
|
||||
class AlbumQueries {
|
||||
const AlbumQueries();
|
||||
@ -27,19 +30,42 @@ class AlbumQueries {
|
||||
);
|
||||
}
|
||||
|
||||
Query<List<TrackSimple>, dynamic> tracksOf(
|
||||
WidgetRef ref,
|
||||
String albumId,
|
||||
) {
|
||||
return useSpotifyQuery<List<TrackSimple>, dynamic>(
|
||||
"album-tracks/$albumId",
|
||||
(spotify) {
|
||||
return spotify.albums
|
||||
.getTracks(albumId)
|
||||
.all()
|
||||
.then((value) => value.toList());
|
||||
static final tracksOfJob = InfiniteQueryJob.withVariableKey<
|
||||
List<Track>,
|
||||
dynamic,
|
||||
int,
|
||||
({
|
||||
SpotifyApi spotify,
|
||||
AlbumSimple album,
|
||||
})>(
|
||||
baseQueryKey: "album-tracks",
|
||||
initialPage: 0,
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -166,17 +166,14 @@ class PlaylistQueries {
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Track>> likedTracks(
|
||||
SpotifyApi spotify,
|
||||
WidgetRef ref,
|
||||
) async {
|
||||
Future<List<Track>> likedTracks(SpotifyApi spotify) async {
|
||||
final tracks = await spotify.tracks.me.saved.all();
|
||||
|
||||
return tracks.map((e) => e.track!).toList();
|
||||
}
|
||||
|
||||
Query<List<Track>, dynamic> likedTracksQuery(WidgetRef ref) {
|
||||
final query = useCallback((spotify) => likedTracks(spotify, ref), []);
|
||||
final query = useCallback((spotify) => likedTracks(spotify), []);
|
||||
final context = useContext();
|
||||
|
||||
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) {
|
||||
return useSpotifyQuery<Playlist, dynamic>(
|
||||
"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(
|
||||
WidgetRef ref,
|
||||
) {
|
||||
|
16
pubspec.lock
16
pubspec.lock
@ -969,6 +969,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.6"
|
||||
gap:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: gap
|
||||
sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1861,6 +1869,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -114,6 +114,8 @@ dependencies:
|
||||
url: https://github.com/thielepaul/flutter-draggable-scrollbar.git
|
||||
ref: cfd570035bf393de541d32e9b28808b5d7e602df
|
||||
very_good_infinite_list: ^0.7.1
|
||||
gap: ^3.0.1
|
||||
sliver_tools: ^0.2.12
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.3.2
|
||||
|
Loading…
Reference in New Issue
Block a user