mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55: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:flutter/widgets.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotify/spotify.dart' hide Search;
|
import 'package:spotify/spotify.dart' hide Search;
|
||||||
|
import 'package:spotube/pages/album/album.dart';
|
||||||
import 'package:spotube/pages/home/home.dart';
|
import 'package:spotube/pages/home/home.dart';
|
||||||
import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
|
import 'package:spotube/pages/lastfm_login/lastfm_login.dart';
|
||||||
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
|
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
|
||||||
|
import 'package:spotube/pages/library/playlist_generate/playlist_generate_result.dart';
|
||||||
import 'package:spotube/pages/lyrics/mini_lyrics.dart';
|
import 'package:spotube/pages/lyrics/mini_lyrics.dart';
|
||||||
|
import 'package:spotube/pages/playlist/liked_playlist.dart';
|
||||||
|
import 'package:spotube/pages/playlist/playlist.dart';
|
||||||
import 'package:spotube/pages/search/search.dart';
|
import 'package:spotube/pages/search/search.dart';
|
||||||
import 'package:spotube/pages/settings/blacklist.dart';
|
import 'package:spotube/pages/settings/blacklist.dart';
|
||||||
import 'package:spotube/pages/settings/about.dart';
|
import 'package:spotube/pages/settings/about.dart';
|
||||||
import 'package:spotube/pages/settings/logs.dart';
|
import 'package:spotube/pages/settings/logs.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
import 'package:spotube/components/shared/spotube_page_route.dart';
|
import 'package:spotube/components/shared/spotube_page_route.dart';
|
||||||
import 'package:spotube/pages/album/album.dart';
|
|
||||||
import 'package:spotube/pages/artist/artist.dart';
|
import 'package:spotube/pages/artist/artist.dart';
|
||||||
import 'package:spotube/pages/library/library.dart';
|
import 'package:spotube/pages/library/library.dart';
|
||||||
import 'package:spotube/pages/desktop_login/login_tutorial.dart';
|
import 'package:spotube/pages/desktop_login/login_tutorial.dart';
|
||||||
import 'package:spotube/pages/desktop_login/desktop_login.dart';
|
import 'package:spotube/pages/desktop_login/desktop_login.dart';
|
||||||
import 'package:spotube/pages/lyrics/lyrics.dart';
|
import 'package:spotube/pages/lyrics/lyrics.dart';
|
||||||
import 'package:spotube/pages/playlist/playlist.dart';
|
|
||||||
import 'package:spotube/pages/root/root_app.dart';
|
import 'package:spotube/pages/root/root_app.dart';
|
||||||
import 'package:spotube/pages/settings/settings.dart';
|
import 'package:spotube/pages/settings/settings.dart';
|
||||||
import 'package:spotube/pages/mobile_login/mobile_login.dart';
|
import 'package:spotube/pages/mobile_login/mobile_login.dart';
|
||||||
|
|
||||||
import '../pages/library/playlist_generate/playlist_generate_result.dart';
|
|
||||||
|
|
||||||
final rootNavigatorKey = Catcher2.navigatorKey;
|
final rootNavigatorKey = Catcher2.navigatorKey;
|
||||||
final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
|
final shellRouteNavigatorKey = GlobalKey<NavigatorState>();
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
@ -104,7 +104,9 @@ final router = GoRouter(
|
|||||||
path: "/album/:id",
|
path: "/album/:id",
|
||||||
pageBuilder: (context, state) {
|
pageBuilder: (context, state) {
|
||||||
assert(state.extra is AlbumSimple);
|
assert(state.extra is AlbumSimple);
|
||||||
return SpotubePage(child: AlbumPage(state.extra as AlbumSimple));
|
return SpotubePage(
|
||||||
|
child: AlbumPage(album: state.extra as AlbumSimple),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
@ -119,7 +121,9 @@ final router = GoRouter(
|
|||||||
pageBuilder: (context, state) {
|
pageBuilder: (context, state) {
|
||||||
assert(state.extra is PlaylistSimple);
|
assert(state.extra is PlaylistSimple);
|
||||||
return SpotubePage(
|
return SpotubePage(
|
||||||
child: PlaylistView(state.extra as PlaylistSimple),
|
child: state.pathParameters["id"] == "user-liked-tracks"
|
||||||
|
? LikedPlaylistPage(playlist: state.extra as PlaylistSimple)
|
||||||
|
: PlaylistPage(playlist: state.extra as PlaylistSimple),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -4,9 +4,12 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/extensions/infinite_query.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
|
import 'package:spotube/services/queries/album.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
@ -15,7 +18,7 @@ extension FormattedAlbumType on AlbumType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AlbumCard extends HookConsumerWidget {
|
class AlbumCard extends HookConsumerWidget {
|
||||||
final Album album;
|
final AlbumSimple album;
|
||||||
const AlbumCard(
|
const AlbumCard(
|
||||||
this.album, {
|
this.album, {
|
||||||
Key? key,
|
Key? key,
|
||||||
@ -27,7 +30,9 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
final playing =
|
final playing =
|
||||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||||
|
|
||||||
final queryClient = useQueryClient();
|
final queryClient = useQueryClient();
|
||||||
|
|
||||||
bool isPlaylistPlaying = useMemoized(
|
bool isPlaylistPlaying = useMemoized(
|
||||||
() => playlist.containsCollection(album.id!),
|
() => playlist.containsCollection(album.id!),
|
||||||
[playlist, album.id],
|
[playlist, album.id],
|
||||||
@ -36,6 +41,34 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
final updating = useState(false);
|
final updating = useState(false);
|
||||||
final spotify = ref.watch(spotifyProvider);
|
final spotify = ref.watch(spotifyProvider);
|
||||||
|
|
||||||
|
final scaffoldMessenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
|
||||||
|
Future<List<Track>> fetchAllTrack() async {
|
||||||
|
if (album.tracks != null && album.tracks!.isNotEmpty) {
|
||||||
|
return album.tracks!
|
||||||
|
.map((track) =>
|
||||||
|
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
final job = AlbumQueries.tracksOfJob(album.id!);
|
||||||
|
|
||||||
|
final query = queryClient.createInfiniteQuery(
|
||||||
|
job.queryKey,
|
||||||
|
(page) => job.task(page, (spotify: spotify, album: album)),
|
||||||
|
initialPage: 0,
|
||||||
|
nextPage: job.nextPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
return await query.fetchAllTracks(
|
||||||
|
getAllTracks: () async {
|
||||||
|
final res = await spotify.albums.tracks(album.id!).all();
|
||||||
|
return res
|
||||||
|
.map((e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
|
||||||
|
.toList();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return PlaybuttonCard(
|
return PlaybuttonCard(
|
||||||
imageUrl: TypeConversionUtils.image_X_UrlString(
|
imageUrl: TypeConversionUtils.image_X_UrlString(
|
||||||
album.images,
|
album.images,
|
||||||
@ -54,20 +87,15 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
onPlaybuttonPressed: () async {
|
onPlaybuttonPressed: () async {
|
||||||
updating.value = true;
|
updating.value = true;
|
||||||
try {
|
try {
|
||||||
if (isPlaylistPlaying && playing) {
|
if (isPlaylistPlaying) {
|
||||||
return audioPlayer.pause();
|
return playing ? audioPlayer.pause() : audioPlayer.resume();
|
||||||
} else if (isPlaylistPlaying && !playing) {
|
|
||||||
return audioPlayer.resume();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await playlistNotifier.load(
|
final fetchedTracks = await fetchAllTrack();
|
||||||
album.tracks
|
|
||||||
?.map((e) =>
|
if (fetchedTracks.isEmpty) return;
|
||||||
TypeConversionUtils.simpleTrack_X_Track(e, album))
|
|
||||||
.toList() ??
|
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||||
[],
|
|
||||||
autoPlay: true,
|
|
||||||
);
|
|
||||||
playlistNotifier.addCollection(album.id!);
|
playlistNotifier.addCollection(album.id!);
|
||||||
} finally {
|
} finally {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
@ -80,28 +108,16 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
updating.value = true;
|
updating.value = true;
|
||||||
try {
|
try {
|
||||||
final fetchedTracks =
|
final fetchedTracks = await fetchAllTrack();
|
||||||
await queryClient.fetchQuery<List<TrackSimple>, SpotifyApi>(
|
|
||||||
"album-tracks/${album.id}",
|
|
||||||
() {
|
|
||||||
return spotify.albums
|
|
||||||
.tracks(album.id!)
|
|
||||||
.all()
|
|
||||||
.then((value) => value.toList());
|
|
||||||
},
|
|
||||||
).then(
|
|
||||||
(tracks) => tracks
|
|
||||||
?.map(
|
|
||||||
(e) => TypeConversionUtils.simpleTrack_X_Track(e, album))
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (fetchedTracks == null || fetchedTracks.isEmpty) return;
|
if (fetchedTracks.isEmpty) return;
|
||||||
playlistNotifier.addTracks(fetchedTracks);
|
playlistNotifier.addTracks(fetchedTracks);
|
||||||
playlistNotifier.addCollection(album.id!);
|
playlistNotifier.addCollection(album.id!);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
final snackbar = SnackBar(
|
final snackbar = SnackBar(
|
||||||
content: Text("Added ${album.tracks?.length} tracks to queue"),
|
content: Text(
|
||||||
|
context.l10n.added_to_queue(fetchedTracks.length),
|
||||||
|
),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: "Undo",
|
label: "Undo",
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@ -110,7 +126,8 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
ScaffoldMessenger.maybeOf(context)?.showSnackBar(snackbar);
|
|
||||||
|
scaffoldMessenger?.showSnackBar(snackbar);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
|
@ -18,7 +18,7 @@ import 'package:spotube/components/shared/expandable_search/expandable_search.da
|
|||||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||||
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
|
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
|
||||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/models/local_track.dart';
|
import 'package:spotube/models/local_track.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
@ -199,7 +199,8 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
ExpandableSearchButton(
|
ExpandableSearchButton(
|
||||||
isFiltering: isFiltering,
|
isFiltering: isFiltering.value,
|
||||||
|
onPressed: (value) => isFiltering.value = value,
|
||||||
searchFocus: searchFocus,
|
searchFocus: searchFocus,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
@ -222,7 +223,8 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
ExpandableSearchField(
|
ExpandableSearchField(
|
||||||
searchController: searchController,
|
searchController: searchController,
|
||||||
searchFocus: searchFocus,
|
searchFocus: searchFocus,
|
||||||
isFiltering: isFiltering,
|
isFiltering: isFiltering.value,
|
||||||
|
onChangeFiltering: (value) => isFiltering.value = value,
|
||||||
),
|
),
|
||||||
trackSnapshot.when(
|
trackSnapshot.when(
|
||||||
data: (tracks) {
|
data: (tracks) {
|
||||||
@ -284,7 +286,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () =>
|
loading: () =>
|
||||||
const Expanded(child: ShimmerTrackTile(noSliver: true)),
|
const Expanded(child: ShimmerTrackTileGroup(noSliver: true)),
|
||||||
error: (error, stackTrace) =>
|
error: (error, stackTrace) =>
|
||||||
Text(error.toString() + stackTrace.toString()),
|
Text(error.toString() + stackTrace.toString()),
|
||||||
)
|
)
|
||||||
|
@ -11,7 +11,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
|
|||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
|
import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart';
|
||||||
|
@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||||
|
import 'package:spotube/extensions/infinite_query.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
@ -23,7 +24,7 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||||
final playing =
|
final playing =
|
||||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||||
final queryBowl = QueryClient.of(context);
|
final queryClient = QueryClient.of(context);
|
||||||
final tracks = useState<List<TrackSimple>?>(null);
|
final tracks = useState<List<TrackSimple>?>(null);
|
||||||
bool isPlaylistPlaying = useMemoized(
|
bool isPlaylistPlaying = useMemoized(
|
||||||
() => playlistQueue.containsCollection(playlist.id!),
|
() => playlistQueue.containsCollection(playlist.id!),
|
||||||
@ -34,6 +35,31 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
final spotify = ref.watch(spotifyProvider);
|
final spotify = ref.watch(spotifyProvider);
|
||||||
final me = useQueries.user.me(ref);
|
final me = useQueries.user.me(ref);
|
||||||
|
|
||||||
|
Future<List<Track>> fetchAllTracks() async {
|
||||||
|
if (playlist.id == 'user-liked-tracks') {
|
||||||
|
return await queryClient.fetchQuery(
|
||||||
|
"user-liked-tracks",
|
||||||
|
() => useQueries.playlist.likedTracks(spotify),
|
||||||
|
) ??
|
||||||
|
[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final query = queryClient.createInfiniteQuery<List<Track>, dynamic, int>(
|
||||||
|
"playlist-tracks/${playlist.id}",
|
||||||
|
(page) => useQueries.playlist.tracksOf(page, spotify, playlist.id!),
|
||||||
|
initialPage: 0,
|
||||||
|
nextPage: useQueries.playlist.tracksOfQueryNextPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
return await query.fetchAllTracks(
|
||||||
|
getAllTracks: () async {
|
||||||
|
final res =
|
||||||
|
await spotify.playlists.getTracksByPlaylistId(playlist.id!).all();
|
||||||
|
return res.toList();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return PlaybuttonCard(
|
return PlaybuttonCard(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 10),
|
margin: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
title: playlist.name!,
|
title: playlist.name!,
|
||||||
@ -62,18 +88,7 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
return audioPlayer.resume();
|
return audioPlayer.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Track> fetchedTracks = playlist.id == 'user-liked-tracks'
|
List<Track> fetchedTracks = await fetchAllTracks();
|
||||||
? await queryBowl.fetchQuery(
|
|
||||||
"user-liked-tracks",
|
|
||||||
() => useQueries.playlist.likedTracks(spotify, ref),
|
|
||||||
) ??
|
|
||||||
[]
|
|
||||||
: await queryBowl.fetchQuery(
|
|
||||||
"playlist-tracks/${playlist.id}",
|
|
||||||
() => useQueries.playlist
|
|
||||||
.tracksOf(playlist.id!, spotify, ref),
|
|
||||||
) ??
|
|
||||||
[];
|
|
||||||
|
|
||||||
if (fetchedTracks.isEmpty) return;
|
if (fetchedTracks.isEmpty) return;
|
||||||
|
|
||||||
@ -90,11 +105,8 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
updating.value = true;
|
updating.value = true;
|
||||||
try {
|
try {
|
||||||
if (isPlaylistPlaying) return;
|
if (isPlaylistPlaying) return;
|
||||||
List<Track> fetchedTracks = await queryBowl.fetchQuery(
|
|
||||||
"playlist-tracks/${playlist.id}",
|
final fetchedTracks = await fetchAllTracks();
|
||||||
() => useQueries.playlist.tracksOf(playlist.id!, spotify, ref),
|
|
||||||
) ??
|
|
||||||
[];
|
|
||||||
|
|
||||||
if (fetchedTracks.isEmpty) return;
|
if (fetchedTracks.isEmpty) return;
|
||||||
|
|
||||||
|
@ -4,13 +4,15 @@ import 'package:spotube/collections/spotube_icons.dart';
|
|||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
|
||||||
class ExpandableSearchField extends StatelessWidget {
|
class ExpandableSearchField extends StatelessWidget {
|
||||||
final ValueNotifier<bool> isFiltering;
|
final bool isFiltering;
|
||||||
|
final ValueChanged<bool> onChangeFiltering;
|
||||||
final TextEditingController searchController;
|
final TextEditingController searchController;
|
||||||
final FocusNode searchFocus;
|
final FocusNode searchFocus;
|
||||||
|
|
||||||
const ExpandableSearchField({
|
const ExpandableSearchField({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.isFiltering,
|
required this.isFiltering,
|
||||||
|
required this.onChangeFiltering,
|
||||||
required this.searchController,
|
required this.searchController,
|
||||||
required this.searchFocus,
|
required this.searchFocus,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@ -19,17 +21,17 @@ class ExpandableSearchField extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedOpacity(
|
return AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
opacity: isFiltering.value ? 1 : 0,
|
opacity: isFiltering ? 1 : 0,
|
||||||
child: AnimatedSize(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: isFiltering.value ? 50 : 0,
|
height: isFiltering ? 50 : 0,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: CallbackShortcuts(
|
child: CallbackShortcuts(
|
||||||
bindings: {
|
bindings: {
|
||||||
LogicalKeySet(LogicalKeyboardKey.escape): () {
|
LogicalKeySet(LogicalKeyboardKey.escape): () {
|
||||||
isFiltering.value = false;
|
onChangeFiltering(false);
|
||||||
searchController.clear();
|
searchController.clear();
|
||||||
searchFocus.unfocus();
|
searchFocus.unfocus();
|
||||||
}
|
}
|
||||||
@ -52,7 +54,7 @@ class ExpandableSearchField extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ExpandableSearchButton extends StatelessWidget {
|
class ExpandableSearchButton extends StatelessWidget {
|
||||||
final ValueNotifier<bool> isFiltering;
|
final bool isFiltering;
|
||||||
final FocusNode searchFocus;
|
final FocusNode searchFocus;
|
||||||
final Widget icon;
|
final Widget icon;
|
||||||
final ValueChanged<bool>? onPressed;
|
final ValueChanged<bool>? onPressed;
|
||||||
@ -73,18 +75,17 @@ class ExpandableSearchButton extends StatelessWidget {
|
|||||||
icon: icon,
|
icon: icon,
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
isFiltering.value ? theme.colorScheme.secondaryContainer : null,
|
isFiltering ? theme.colorScheme.secondaryContainer : null,
|
||||||
foregroundColor: isFiltering.value ? theme.colorScheme.secondary : null,
|
foregroundColor: isFiltering ? theme.colorScheme.secondary : null,
|
||||||
minimumSize: const Size(25, 25),
|
minimumSize: const Size(25, 25),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
isFiltering.value = !isFiltering.value;
|
if (isFiltering) {
|
||||||
if (isFiltering.value) {
|
|
||||||
searchFocus.requestFocus();
|
searchFocus.requestFocus();
|
||||||
} else {
|
} else {
|
||||||
searchFocus.unfocus();
|
searchFocus.unfocus();
|
||||||
}
|
}
|
||||||
onPressed?.call(isFiltering.value);
|
onPressed?.call(!isFiltering);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ class ShimmerArtistProfile extends HookWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
const Flexible(child: ShimmerTrackTile(noSliver: true)),
|
const Flexible(child: ShimmerTrackTileGroup(noSliver: true)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -70,8 +70,7 @@ class ShimmerTrackTilePainter extends CustomPainter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ShimmerTrackTile extends StatelessWidget {
|
class ShimmerTrackTile extends StatelessWidget {
|
||||||
final bool noSliver;
|
const ShimmerTrackTile({super.key});
|
||||||
const ShimmerTrackTile({super.key, this.noSliver = false});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -82,10 +81,6 @@ class ShimmerTrackTile extends StatelessWidget {
|
|||||||
shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300],
|
shimmerColor: isDark ? Colors.grey[800] : Colors.grey[300],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (noSliver) {
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: 5,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8),
|
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8),
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
@ -97,24 +92,31 @@ class ShimmerTrackTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShimmerTrackTileGroup extends StatelessWidget {
|
||||||
|
final bool noSliver;
|
||||||
|
final int count;
|
||||||
|
const ShimmerTrackTileGroup({
|
||||||
|
super.key,
|
||||||
|
this.noSliver = false,
|
||||||
|
this.count = 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (noSliver) {
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: 5,
|
||||||
|
itemBuilder: (context, index) => const ShimmerTrackTile(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SliverList(
|
return SliverList(
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(BuildContext context, int index) => Padding(
|
(BuildContext context, int index) => const ShimmerTrackTile(),
|
||||||
padding: const EdgeInsets.only(bottom: 8.0, left: 8, right: 8),
|
childCount: count,
|
||||||
child: CustomPaint(
|
|
||||||
size: const Size(double.infinity, 60),
|
|
||||||
painter: ShimmerTrackTilePainter(
|
|
||||||
background: shimmerTheme.shimmerBackgroundColor ??
|
|
||||||
theme.scaffoldBackgroundColor,
|
|
||||||
foreground: shimmerTheme.shimmerColor ?? theme.cardColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
childCount: 5,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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/hover_builder.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
import 'package:spotube/components/shared/links/link_text.dart';
|
import 'package:spotube/components/shared/links/link_text.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_options.dart';
|
import 'package:spotube/components/shared/track_tile/track_options.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/extensions/duration.dart';
|
import 'package:spotube/extensions/duration.dart';
|
||||||
import 'package:spotube/models/local_track.dart';
|
import 'package:spotube/models/local_track.dart';
|
@ -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/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/shared/heart_button.dart';
|
import 'package:spotube/components/shared/tracks_view/track_view.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
|
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
|
import 'package:spotube/extensions/infinite_query.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/services/mutations/mutations.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
class AlbumPage extends HookConsumerWidget {
|
class AlbumPage extends HookConsumerWidget {
|
||||||
final AlbumSimple album;
|
final AlbumSimple album;
|
||||||
const AlbumPage(this.album, {Key? key}) : super(key: key);
|
const AlbumPage({
|
||||||
|
Key? key,
|
||||||
Future<void> playPlaylist(
|
required this.album,
|
||||||
List<Track> tracks,
|
}) : super(key: key);
|
||||||
WidgetRef ref, {
|
|
||||||
Track? currentTrack,
|
|
||||||
}) async {
|
|
||||||
final playlist = ref.read(ProxyPlaylistNotifier.provider);
|
|
||||||
final playback = ref.read(ProxyPlaylistNotifier.notifier);
|
|
||||||
final sortBy = ref.read(trackCollectionSortState(album.id!));
|
|
||||||
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
|
|
||||||
currentTrack ??= sortedTracks.first;
|
|
||||||
final isAlbumPlaying = playlist.containsTracks(tracks);
|
|
||||||
if (!isAlbumPlaying) {
|
|
||||||
playback.addCollection(album.id!); // for enabling loading indicator
|
|
||||||
await playback.load(
|
|
||||||
sortedTracks,
|
|
||||||
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
|
||||||
);
|
|
||||||
playback.addCollection(album.id!);
|
|
||||||
} else if (isAlbumPlaying &&
|
|
||||||
currentTrack.id != null &&
|
|
||||||
currentTrack.id != playlist.activeTrack?.id) {
|
|
||||||
await playback.jumpToTrack(currentTrack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
final spotify = ref.watch(spotifyProvider);
|
||||||
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
|
final tracksQuery = useQueries.album.tracksOf(ref, album);
|
||||||
|
|
||||||
final tracksSnapshot = useQueries.album.tracksOf(ref, album.id!);
|
final tracks = useMemoized(() {
|
||||||
|
return tracksQuery.pages.expand((element) => element).toList();
|
||||||
|
}, [tracksQuery.pages]);
|
||||||
|
|
||||||
final albumArt = useMemoized(
|
final client = useQueryClient();
|
||||||
() => TypeConversionUtils.image_X_UrlString(
|
|
||||||
|
final albumIsSaved = useQueries.album.isSavedForMe(ref, album.id!);
|
||||||
|
final isLiked = albumIsSaved.data ?? false;
|
||||||
|
|
||||||
|
final toggleAlbumLike = useMutations.album.toggleFavorite(
|
||||||
|
ref,
|
||||||
|
album.id!,
|
||||||
|
refreshQueries: [albumIsSaved.key],
|
||||||
|
onData: (_, __) async {
|
||||||
|
await client.refreshInfiniteQueryAllPages("current-user-albums");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return InheritedTrackView(
|
||||||
|
collectionId: album.id!,
|
||||||
|
image: TypeConversionUtils.image_X_UrlString(
|
||||||
album.images,
|
album.images,
|
||||||
placeholder: ImagePlaceholder.albumArt,
|
placeholder: ImagePlaceholder.albumArt,
|
||||||
),
|
),
|
||||||
[album.images]);
|
|
||||||
|
|
||||||
final mediaQuery = MediaQuery.of(context);
|
|
||||||
|
|
||||||
final isAlbumPlaying = useMemoized(
|
|
||||||
() => playlist.collections.contains(album.id!),
|
|
||||||
[playlist, album],
|
|
||||||
);
|
|
||||||
|
|
||||||
final albumTrackPlaying = useMemoized(
|
|
||||||
() =>
|
|
||||||
tracksSnapshot.data?.any((s) => s.id! == playlist.activeTrack?.id!) ==
|
|
||||||
true &&
|
|
||||||
playlist.activeTrack is SourcedTrack,
|
|
||||||
[playlist.activeTrack, tracksSnapshot.data],
|
|
||||||
);
|
|
||||||
|
|
||||||
return TrackCollectionView(
|
|
||||||
id: album.id!,
|
|
||||||
playingState: isAlbumPlaying && albumTrackPlaying
|
|
||||||
? PlayButtonState.playing
|
|
||||||
: isAlbumPlaying && !albumTrackPlaying
|
|
||||||
? PlayButtonState.loading
|
|
||||||
: PlayButtonState.notPlaying,
|
|
||||||
title: album.name!,
|
title: album.name!,
|
||||||
titleImage: albumArt,
|
description:
|
||||||
tracksSnapshot: tracksSnapshot,
|
"${context.l10n.released} • ${album.releaseDate} • ${album.artists!.first.name}",
|
||||||
album: album,
|
tracks: tracks,
|
||||||
|
pagination: PaginationProps.fromQuery(
|
||||||
|
tracksQuery,
|
||||||
|
onFetchAll: () {
|
||||||
|
return tracksQuery.fetchAllTracks(getAllTracks: () async {
|
||||||
|
final res = await spotify.albums.tracks(album.id!).all();
|
||||||
|
|
||||||
|
return res
|
||||||
|
.map((track) =>
|
||||||
|
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
routePath: "/album/${album.id}",
|
routePath: "/album/${album.id}",
|
||||||
bottomSpace: mediaQuery.mdAndDown,
|
shareUrl: album.externalUrls!.spotify!,
|
||||||
onPlay: ([track]) async {
|
isLiked: isLiked,
|
||||||
if (tracksSnapshot.hasData) {
|
onHeart: albumIsSaved.hasData
|
||||||
if (!isAlbumPlaying) {
|
? () {
|
||||||
await playPlaylist(
|
toggleAlbumLike.mutate(isLiked);
|
||||||
tracksSnapshot.data!
|
|
||||||
.map((track) =>
|
|
||||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
|
||||||
.toList(),
|
|
||||||
ref,
|
|
||||||
);
|
|
||||||
} else if (isAlbumPlaying && track != null) {
|
|
||||||
await playPlaylist(
|
|
||||||
tracksSnapshot.data!
|
|
||||||
.map((track) =>
|
|
||||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
|
||||||
.toList(),
|
|
||||||
currentTrack: track,
|
|
||||||
ref,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await playback
|
|
||||||
.removeTracks(tracksSnapshot.data!.map((track) => track.id!));
|
|
||||||
}
|
}
|
||||||
}
|
: null,
|
||||||
},
|
child: const TrackView(),
|
||||||
onAddToQueue: () {
|
|
||||||
if (tracksSnapshot.hasData && !isAlbumPlaying) {
|
|
||||||
playback.addTracks(
|
|
||||||
tracksSnapshot.data!
|
|
||||||
.map((track) =>
|
|
||||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
playback.addCollection(album.id!);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onShare: () {
|
|
||||||
Clipboard.setData(
|
|
||||||
ClipboardData(text: "https://open.spotify.com/album/${album.id}"),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
heartBtn: AlbumHeartButton(album: album),
|
|
||||||
onShuffledPlay: ([track]) {
|
|
||||||
// Shuffle the tracks (create a copy of playlist)
|
|
||||||
if (tracksSnapshot.hasData) {
|
|
||||||
final tracks = tracksSnapshot.data!
|
|
||||||
.map((track) =>
|
|
||||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
|
||||||
.toList()
|
|
||||||
..shuffle();
|
|
||||||
if (!isAlbumPlaying) {
|
|
||||||
playPlaylist(
|
|
||||||
tracks,
|
|
||||||
ref,
|
|
||||||
);
|
|
||||||
} else if (isAlbumPlaying && track != null) {
|
|
||||||
playPlaylist(
|
|
||||||
tracks,
|
|
||||||
ref,
|
|
||||||
currentTrack: track,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// TODO: Disable ability to stop playback from playlist/album
|
|
||||||
// playback.stop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import 'package:spotube/collections/spotube_icons.dart';
|
|||||||
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
|
||||||
import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart';
|
import 'package:spotube/components/shared/shimmers/shimmer_artist_profile.dart';
|
||||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
import 'package:spotube/components/artist/artist_album_list.dart';
|
import 'package:spotube/components/artist/artist_album_list.dart';
|
||||||
import 'package:spotube/components/artist/artist_card.dart';
|
import 'package:spotube/components/artist/artist_card.dart';
|
||||||
|
@ -70,7 +70,8 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
ExpandableSearchField(
|
ExpandableSearchField(
|
||||||
isFiltering: isFiltering,
|
isFiltering: isFiltering.value,
|
||||||
|
onChangeFiltering: (value) => isFiltering.value = value,
|
||||||
searchController: searchController,
|
searchController: searchController,
|
||||||
searchFocus: searchFocus,
|
searchFocus: searchFocus,
|
||||||
),
|
),
|
||||||
@ -103,10 +104,11 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
top: 0,
|
top: 0,
|
||||||
right: 10,
|
right: 10,
|
||||||
child: ExpandableSearchButton(
|
child: ExpandableSearchButton(
|
||||||
isFiltering: isFiltering,
|
isFiltering: isFiltering.value,
|
||||||
searchFocus: searchFocus,
|
searchFocus: searchFocus,
|
||||||
icon: const Icon(SpotubeIcons.search),
|
icon: const Icon(SpotubeIcons.search),
|
||||||
onPressed: (value) {
|
onPressed: (value) {
|
||||||
|
isFiltering.value = value;
|
||||||
if (isFiltering.value) {
|
if (isFiltering.value) {
|
||||||
scrollController.animateTo(
|
scrollController.animateTo(
|
||||||
0,
|
0,
|
||||||
|
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:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
|
||||||
import 'package:spotube/components/shared/heart_button.dart';
|
|
||||||
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
|
|
||||||
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
|
|
||||||
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
|
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
|
||||||
import 'package:spotube/models/logger.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/components/shared/tracks_view/track_view.dart';
|
||||||
|
import 'package:spotube/components/shared/tracks_view/track_view_props.dart';
|
||||||
|
import 'package:spotube/extensions/infinite_query.dart';
|
||||||
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
import 'package:spotube/services/mutations/mutations.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
|
||||||
|
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
class PlaylistView extends HookConsumerWidget {
|
class PlaylistPage extends HookConsumerWidget {
|
||||||
final logger = getLogger(PlaylistView);
|
final PlaylistSimple playlist;
|
||||||
final PlaylistSimple playlistSimple;
|
const PlaylistPage({
|
||||||
PlaylistView(this.playlistSimple, {Key? key}) : super(key: key);
|
Key? key,
|
||||||
|
required this.playlist,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final proxyPlaylist = ref.watch(ProxyPlaylistNotifier.provider);
|
final spotify = ref.watch(spotifyProvider);
|
||||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
final tracksQuery = useQueries.playlist.tracksOfQuery(ref, playlist.id!);
|
||||||
|
|
||||||
final mediaQuery = MediaQuery.of(context);
|
final tracks = useMemoized(
|
||||||
|
() {
|
||||||
final meSnapshot = useQueries.user.me(ref);
|
return tracksQuery.pages.expand((page) => page).toList();
|
||||||
|
},
|
||||||
final playlistQuery = useQueries.playlist.byId(ref, playlistSimple.id!);
|
[tracksQuery.pages],
|
||||||
final playlist = playlistQuery.data ?? playlistSimple;
|
|
||||||
|
|
||||||
final playlistTrackSnapshot =
|
|
||||||
useQueries.playlist.tracksOfQuery(ref, playlist.id!);
|
|
||||||
final likedTracksSnapshot = useQueries.playlist.likedTracksQuery(ref);
|
|
||||||
final tracksSnapshot = playlist.id! == "user-liked-tracks"
|
|
||||||
? likedTracksSnapshot
|
|
||||||
: playlistTrackSnapshot;
|
|
||||||
|
|
||||||
final isPlaylistPlaying = useMemoized(
|
|
||||||
() => proxyPlaylist.collections.contains(playlist.id!),
|
|
||||||
[proxyPlaylist, playlist],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final titleImage = useMemoized(
|
final me = useQueries.user.me(ref);
|
||||||
() => TypeConversionUtils.image_X_UrlString(
|
|
||||||
|
final isLikedQuery = useQueries.playlist.doesUserFollow(
|
||||||
|
ref,
|
||||||
|
playlist.id!,
|
||||||
|
me.data?.id ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
final togglePlaylistLike = useMutations.playlist.toggleFavorite(
|
||||||
|
ref,
|
||||||
|
playlist.id!,
|
||||||
|
refreshQueries: [
|
||||||
|
isLikedQuery.key,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return InheritedTrackView(
|
||||||
|
collectionId: playlist.id!,
|
||||||
|
image: TypeConversionUtils.image_X_UrlString(
|
||||||
playlist.images,
|
playlist.images,
|
||||||
placeholder: ImagePlaceholder.collection,
|
placeholder: ImagePlaceholder.collection,
|
||||||
),
|
),
|
||||||
[playlist.images]);
|
pagination: PaginationProps.fromQuery(
|
||||||
|
tracksQuery,
|
||||||
final playlistTrackPlaying = useMemoized(
|
onFetchAll: () async {
|
||||||
() =>
|
return tracksQuery.fetchAllTracks(
|
||||||
tracksSnapshot.data
|
getAllTracks: () async {
|
||||||
?.any((s) => s.id! == proxyPlaylist.activeTrack?.id!) ==
|
final res = await spotify.playlists
|
||||||
true &&
|
.getTracksByPlaylistId(playlist.id!)
|
||||||
proxyPlaylist.activeTrack is SourcedTrack,
|
.all();
|
||||||
[proxyPlaylist.activeTrack, tracksSnapshot.data],
|
return res.toList();
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
final playPlaylist = useCallback((
|
),
|
||||||
List<Track> tracks,
|
|
||||||
WidgetRef ref, {
|
|
||||||
Track? currentTrack,
|
|
||||||
}) async {
|
|
||||||
final playback = ref.read(ProxyPlaylistNotifier.notifier);
|
|
||||||
final sortBy = ref.read(trackCollectionSortState(playlist.id!));
|
|
||||||
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
|
|
||||||
currentTrack ??= sortedTracks.first;
|
|
||||||
final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks);
|
|
||||||
if (!isPlaylistPlaying) {
|
|
||||||
playback.addCollection(playlist.id!); // for enabling loading indicator
|
|
||||||
await playback.load(
|
|
||||||
sortedTracks,
|
|
||||||
initialIndex:
|
|
||||||
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
|
||||||
autoPlay: true,
|
|
||||||
);
|
|
||||||
playback.addCollection(playlist.id!);
|
|
||||||
} else if (isPlaylistPlaying &&
|
|
||||||
currentTrack.id != null &&
|
|
||||||
currentTrack.id != proxyPlaylist.activeTrack?.id) {
|
|
||||||
await playback.jumpToTrack(currentTrack);
|
|
||||||
}
|
|
||||||
}, [proxyPlaylist, playlist]);
|
|
||||||
|
|
||||||
final ownPlaylist =
|
|
||||||
playlist.owner?.id != null && playlist.owner?.id == meSnapshot.data?.id;
|
|
||||||
|
|
||||||
return TrackCollectionView(
|
|
||||||
id: playlist.id!,
|
|
||||||
playingState: isPlaylistPlaying && playlistTrackPlaying
|
|
||||||
? PlayButtonState.playing
|
|
||||||
: isPlaylistPlaying && !playlistTrackPlaying
|
|
||||||
? PlayButtonState.loading
|
|
||||||
: PlayButtonState.notPlaying,
|
|
||||||
title: playlist.name!,
|
title: playlist.name!,
|
||||||
titleImage: titleImage,
|
|
||||||
tracksSnapshot: tracksSnapshot,
|
|
||||||
description: playlist.description,
|
description: playlist.description,
|
||||||
isOwned: ownPlaylist,
|
tracks: tracks,
|
||||||
onPlay: ([track]) async {
|
routePath: '/playlist/${playlist.id}',
|
||||||
if (tracksSnapshot.hasData) {
|
isLiked: isLikedQuery.data ?? false,
|
||||||
if (!isPlaylistPlaying || (isPlaylistPlaying && track != null)) {
|
shareUrl: playlist.externalUrls?.spotify ?? "",
|
||||||
await playPlaylist(
|
onHeart: () async {
|
||||||
tracksSnapshot.data!,
|
if (!isLikedQuery.hasData || togglePlaylistLike.isMutating) {
|
||||||
ref,
|
return;
|
||||||
currentTrack: track,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await playlistNotifier
|
|
||||||
.removeTracks(tracksSnapshot.data!.map((e) => e.id!));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onAddToQueue: () {
|
|
||||||
if (tracksSnapshot.hasData && !isPlaylistPlaying) {
|
|
||||||
playlistNotifier.addTracks(tracksSnapshot.data!);
|
|
||||||
playlistNotifier.addCollection(playlist.id!);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
bottomSpace: mediaQuery.mdAndDown,
|
|
||||||
showShare: playlist.id != "user-liked-tracks",
|
|
||||||
routePath: "/playlist/${playlist.id}",
|
|
||||||
onShare: () {
|
|
||||||
final data = "https://open.spotify.com/playlist/${playlist.id}";
|
|
||||||
Clipboard.setData(
|
|
||||||
ClipboardData(text: data),
|
|
||||||
).then((_) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
width: 300,
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
content: Text(
|
|
||||||
"Copied $data to clipboard",
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
heartBtn: PlaylistHeartButton(
|
|
||||||
playlist: playlist,
|
|
||||||
icon: ownPlaylist ? SpotubeIcons.trash : null,
|
|
||||||
onData: (data) {
|
|
||||||
GoRouter.of(context).pop();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
onShuffledPlay: ([track]) {
|
|
||||||
final tracks = [...?tracksSnapshot.data]..shuffle();
|
|
||||||
|
|
||||||
if (tracksSnapshot.hasData) {
|
|
||||||
if (!isPlaylistPlaying) {
|
|
||||||
playPlaylist(
|
|
||||||
tracks,
|
|
||||||
ref,
|
|
||||||
currentTrack: track,
|
|
||||||
);
|
|
||||||
} else if (isPlaylistPlaying && track != null) {
|
|
||||||
playPlaylist(
|
|
||||||
tracks,
|
|
||||||
ref,
|
|
||||||
currentTrack: track,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// TODO: Remove the ability to stop the playlist
|
|
||||||
// playlistNotifier.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
await togglePlaylistLike.mutate(isLikedQuery.data!);
|
||||||
},
|
},
|
||||||
|
child: const TrackView(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
|
import 'package:spotube/components/shared/dialogs/prompt_dialog.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
import 'package:spotube/components/shared/track_tile/track_tile.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
|
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import 'package:catcher_2/catcher_2.dart';
|
import 'package:catcher_2/catcher_2.dart';
|
||||||
import 'package:fl_query/fl_query.dart';
|
import 'package:fl_query/fl_query.dart';
|
||||||
|
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart';
|
import 'package:spotube/hooks/spotify/use_spotify_infinite_query.dart';
|
||||||
import 'package:spotube/hooks/spotify/use_spotify_query.dart';
|
import 'package:spotube/hooks/spotify/use_spotify_query.dart';
|
||||||
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
class AlbumQueries {
|
class AlbumQueries {
|
||||||
const AlbumQueries();
|
const AlbumQueries();
|
||||||
@ -27,19 +30,42 @@ class AlbumQueries {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Query<List<TrackSimple>, dynamic> tracksOf(
|
static final tracksOfJob = InfiniteQueryJob.withVariableKey<
|
||||||
WidgetRef ref,
|
List<Track>,
|
||||||
String albumId,
|
dynamic,
|
||||||
) {
|
int,
|
||||||
return useSpotifyQuery<List<TrackSimple>, dynamic>(
|
({
|
||||||
"album-tracks/$albumId",
|
SpotifyApi spotify,
|
||||||
(spotify) {
|
AlbumSimple album,
|
||||||
return spotify.albums
|
})>(
|
||||||
.getTracks(albumId)
|
baseQueryKey: "album-tracks",
|
||||||
.all()
|
initialPage: 0,
|
||||||
.then((value) => value.toList());
|
task: (albumId, page, args) async {
|
||||||
|
final res =
|
||||||
|
await args!.spotify.albums.tracks(albumId).getPage(20, page * 20);
|
||||||
|
return res.items
|
||||||
|
?.map((track) =>
|
||||||
|
TypeConversionUtils.simpleTrack_X_Track(track, args.album))
|
||||||
|
.toList() ??
|
||||||
|
<Track>[];
|
||||||
},
|
},
|
||||||
ref: ref,
|
nextPage: (lastPage, lastPageData) {
|
||||||
|
if (lastPageData.length < 20) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return lastPage + 1;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
InfiniteQuery<List<Track>, dynamic, int> tracksOf(
|
||||||
|
WidgetRef ref,
|
||||||
|
AlbumSimple album,
|
||||||
|
) {
|
||||||
|
final spotify = ref.watch(spotifyProvider);
|
||||||
|
|
||||||
|
return useInfiniteQueryJob(
|
||||||
|
job: tracksOfJob(album.id!),
|
||||||
|
args: (spotify: spotify, album: album),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -166,17 +166,14 @@ class PlaylistQueries {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Track>> likedTracks(
|
Future<List<Track>> likedTracks(SpotifyApi spotify) async {
|
||||||
SpotifyApi spotify,
|
|
||||||
WidgetRef ref,
|
|
||||||
) async {
|
|
||||||
final tracks = await spotify.tracks.me.saved.all();
|
final tracks = await spotify.tracks.me.saved.all();
|
||||||
|
|
||||||
return tracks.map((e) => e.track!).toList();
|
return tracks.map((e) => e.track!).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Query<List<Track>, dynamic> likedTracksQuery(WidgetRef ref) {
|
Query<List<Track>, dynamic> likedTracksQuery(WidgetRef ref) {
|
||||||
final query = useCallback((spotify) => likedTracks(spotify, ref), []);
|
final query = useCallback((spotify) => likedTracks(spotify), []);
|
||||||
final context = useContext();
|
final context = useContext();
|
||||||
|
|
||||||
return useSpotifyQuery<List<Track>, dynamic>(
|
return useSpotifyQuery<List<Track>, dynamic>(
|
||||||
@ -201,28 +198,6 @@ class PlaylistQueries {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Track>> tracksOf(
|
|
||||||
String playlistId,
|
|
||||||
SpotifyApi spotify,
|
|
||||||
WidgetRef ref,
|
|
||||||
) async {
|
|
||||||
if (playlistId == "user-liked-tracks") return <Track>[];
|
|
||||||
return spotify.playlists.getTracksByPlaylistId(playlistId).all().then(
|
|
||||||
(value) => value.where((track) => track.id != null).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Query<List<Track>, dynamic> tracksOfQuery(
|
|
||||||
WidgetRef ref,
|
|
||||||
String playlistId,
|
|
||||||
) {
|
|
||||||
return useSpotifyQuery<List<Track>, dynamic>(
|
|
||||||
"playlist-tracks/$playlistId",
|
|
||||||
(spotify) => tracksOf(playlistId, spotify, ref),
|
|
||||||
ref: ref,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Query<Playlist, dynamic> byId(WidgetRef ref, String id) {
|
Query<Playlist, dynamic> byId(WidgetRef ref, String id) {
|
||||||
return useSpotifyQuery<Playlist, dynamic>(
|
return useSpotifyQuery<Playlist, dynamic>(
|
||||||
"playlist/$id",
|
"playlist/$id",
|
||||||
@ -233,6 +208,42 @@ class PlaylistQueries {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Track>> tracksOf(
|
||||||
|
int pageParam,
|
||||||
|
SpotifyApi spotify,
|
||||||
|
String playlistId,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final playlists = await spotify.playlists
|
||||||
|
.getTracksByPlaylistId(playlistId)
|
||||||
|
.getPage(20, pageParam * 20);
|
||||||
|
return playlists.items?.toList() ?? <Track>[];
|
||||||
|
} catch (e, stack) {
|
||||||
|
Catcher2.reportCheckedError(e, stack);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? tracksOfQueryNextPage(int lastPage, List<Track> lastPageData) {
|
||||||
|
if (lastPageData.length < 20) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return lastPage + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
InfiniteQuery<List<Track>, dynamic, int> tracksOfQuery(
|
||||||
|
WidgetRef ref,
|
||||||
|
String playlistId,
|
||||||
|
) {
|
||||||
|
return useSpotifyInfiniteQuery<List<Track>, dynamic, int>(
|
||||||
|
"playlist-tracks/$playlistId",
|
||||||
|
(page, spotify) => tracksOf(page, spotify, playlistId),
|
||||||
|
initialPage: 0,
|
||||||
|
nextPage: tracksOfQueryNextPage,
|
||||||
|
ref: ref,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
InfiniteQuery<Page<PlaylistSimple>, dynamic, int> featured(
|
InfiniteQuery<Page<PlaylistSimple>, dynamic, int> featured(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
) {
|
) {
|
||||||
|
16
pubspec.lock
16
pubspec.lock
@ -969,6 +969,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.6"
|
version: "1.1.6"
|
||||||
|
gap:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: gap
|
||||||
|
sha256: f19387d4e32f849394758b91377f9153a1b41d79513ef7668c088c77dbc6955d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.1"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -1861,6 +1869,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.99"
|
version: "0.0.99"
|
||||||
|
sliver_tools:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: sliver_tools
|
||||||
|
sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.12"
|
||||||
smtc_windows:
|
smtc_windows:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -114,6 +114,8 @@ dependencies:
|
|||||||
url: https://github.com/thielepaul/flutter-draggable-scrollbar.git
|
url: https://github.com/thielepaul/flutter-draggable-scrollbar.git
|
||||||
ref: cfd570035bf393de541d32e9b28808b5d7e602df
|
ref: cfd570035bf393de541d32e9b28808b5d7e602df
|
||||||
very_good_infinite_list: ^0.7.1
|
very_good_infinite_list: ^0.7.1
|
||||||
|
gap: ^3.0.1
|
||||||
|
sliver_tools: ^0.2.12
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.3.2
|
build_runner: ^2.3.2
|
||||||
|
Loading…
Reference in New Issue
Block a user