diff --git a/lib/components/Album/AlbumView.dart b/lib/components/Album/AlbumView.dart index 920437d2..68ffef6c 100644 --- a/lib/components/Album/AlbumView.dart +++ b/lib/components/Album/AlbumView.dart @@ -67,6 +67,13 @@ class AlbumView extends HookConsumerWidget { future: spotify.me.isSavedAlbums([album.id!]), builder: (context, snapshot) { final isSaved = snapshot.data?.first == true; + if (!snapshot.hasData && !snapshot.hasError) { + return const SizedBox( + height: 25, + width: 25, + child: CircularProgressIndicator.adaptive(), + ); + } return HeartButton( isLiked: isSaved, onPressed: () { diff --git a/lib/components/Artist/ArtistProfile.dart b/lib/components/Artist/ArtistProfile.dart index f799cec5..e2f17ef2 100644 --- a/lib/components/Artist/ArtistProfile.dart +++ b/lib/components/Artist/ArtistProfile.dart @@ -8,7 +8,7 @@ import 'package:spotify/spotify.dart'; import 'package:spotube/components/Album/AlbumCard.dart'; import 'package:spotube/components/Artist/ArtistCard.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; -import 'package:spotube/components/Shared/TracksTableView.dart'; +import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/readable-number.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart'; diff --git a/lib/components/Home/Home.dart b/lib/components/Home/Home.dart index a7b4ea6d..50be0c6d 100644 --- a/lib/components/Home/Home.dart +++ b/lib/components/Home/Home.dart @@ -169,25 +169,27 @@ class Home extends HookConsumerWidget { }; }, [localStorage]); - final titleBarContents = Row( - children: [ - Expanded( - child: Row( + final titleBarContents = Container( + color: Theme.of(context).backgroundColor, + child: Row( children: [ - Container( - constraints: BoxConstraints( - maxWidth: titleBarDragMaxWidth.toDouble(), - ), - color: Theme.of(context).navigationRailTheme.backgroundColor, - child: MoveWindow(), - ), - Expanded(child: MoveWindow()), - if (!Platform.isMacOS && !Platform.isAndroid && !Platform.isIOS) - const TitleBarActionButtons(), + Expanded( + child: Row( + children: [ + Container( + constraints: BoxConstraints( + maxWidth: titleBarDragMaxWidth.toDouble(), + ), + color: Theme.of(context).navigationRailTheme.backgroundColor, + child: MoveWindow(), + ), + Expanded(child: MoveWindow()), + if (!Platform.isMacOS && !Platform.isAndroid && !Platform.isIOS) + const TitleBarActionButtons(), + ], + )) ], - )) - ], - ); + )); return SafeArea( child: Scaffold( diff --git a/lib/components/Playlist/PlaylistView.dart b/lib/components/Playlist/PlaylistView.dart index 9f7ab012..d54f21e2 100644 --- a/lib/components/Playlist/PlaylistView.dart +++ b/lib/components/Playlist/PlaylistView.dart @@ -1,3 +1,4 @@ +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; @@ -45,6 +46,12 @@ class PlaylistView extends HookConsumerWidget { final isPlaylistPlaying = playback.currentPlaylist?.id != null && playback.currentPlaylist?.id == playlist.id; final update = useForceUpdate(); + final getMe = useMemoized(() => spotify.me.get(), []); + final meSnapshot = useFuture(getMe); + + Future> isFollowing(User me) { + return spotify.playlists.followedBy(playlist.id!, [me.id!]); + } return SafeArea( child: Scaffold( @@ -64,17 +71,27 @@ class PlaylistView extends HookConsumerWidget { // nav back const BackButton(), // heart playlist - if (auth.isLoggedIn) + if (auth.isLoggedIn && meSnapshot.hasData) FutureBuilder>( - future: spotify.me.get().then( - (me) => spotify.playlists - .followedBy(playlist.id!, [me.id!]), - ), + future: isFollowing(meSnapshot.data!), builder: (context, snapshot) { final isFollowing = snapshot.data?.first ?? false; + + if (!snapshot.hasData && !snapshot.hasError) { + return const SizedBox( + height: 25, + width: 25, + child: CircularProgressIndicator.adaptive(), + ); + } return HeartButton( isLiked: isFollowing, + icon: playlist.owner?.id != null && + meSnapshot.data?.id == + playlist.owner?.id + ? Icons.delete_outline_rounded + : null, onPressed: () async { try { isFollowing @@ -124,6 +141,9 @@ class PlaylistView extends HookConsumerWidget { tracks, currentTrack: currentTrack, ), + playlistId: playlist.id, + userPlaylist: playlist.owner?.id != null && + playlist.owner!.id == meSnapshot.data?.id, ), ], ); diff --git a/lib/components/Search/Search.dart b/lib/components/Search/Search.dart index 73b5f94f..40355366 100644 --- a/lib/components/Search/Search.dart +++ b/lib/components/Search/Search.dart @@ -6,7 +6,7 @@ import 'package:spotube/components/Album/AlbumCard.dart'; import 'package:spotube/components/Artist/ArtistCard.dart'; import 'package:spotube/components/Playlist/PlaylistCard.dart'; import 'package:spotube/components/Shared/AnonymousFallback.dart'; -import 'package:spotube/components/Shared/TracksTableView.dart'; +import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/simple-album-to-album.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart'; @@ -22,8 +22,8 @@ class Search extends HookConsumerWidget { Widget build(BuildContext context, ref) { final SpotifyApi spotify = ref.watch(spotifyProvider); final Auth auth = ref.watch(authProvider); - var controller = useTextEditingController(); - var searchTerm = useState(""); + final controller = useTextEditingController(); + final searchTerm = useState(""); final albumController = useScrollController(); final playlistController = useScrollController(); final artistController = useScrollController(); @@ -34,188 +34,191 @@ class Search extends HookConsumerWidget { } return Expanded( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - child: Row( - children: [ - Expanded( - child: TextField( - decoration: const InputDecoration(hintText: "Search..."), - controller: controller, - onSubmitted: (value) { + child: Container( + color: Theme.of(context).backgroundColor, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Row( + children: [ + Expanded( + child: TextField( + decoration: const InputDecoration(hintText: "Search..."), + controller: controller, + onSubmitted: (value) { + searchTerm.value = controller.value.text; + }, + ), + ), + const SizedBox(width: 5), + MaterialButton( + elevation: 3, + splashColor: Theme.of(context).primaryColor, + padding: const EdgeInsets.symmetric(vertical: 21), + color: Theme.of(context).primaryColor, + textColor: Colors.white, + child: const Icon(Icons.search_rounded), + onPressed: () { searchTerm.value = controller.value.text; }, ), - ), - const SizedBox(width: 5), - MaterialButton( - elevation: 3, - splashColor: Theme.of(context).primaryColor, - padding: const EdgeInsets.symmetric(vertical: 21), - color: Theme.of(context).primaryColor, - textColor: Colors.white, - child: const Icon(Icons.search_rounded), - onPressed: () { - searchTerm.value = controller.value.text; - }, - ), - ], + ], + ), ), - ), - FutureBuilder>( - future: searchTerm.value.isNotEmpty - ? spotify.search.get(searchTerm.value).first(10) - : null, - builder: (context, snapshot) { - if (!snapshot.hasData && searchTerm.value.isNotEmpty) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } else if (!snapshot.hasData && searchTerm.value.isEmpty) { - return Container(); - } - Playback playback = ref.watch(playbackProvider); - List albums = []; - List artists = []; - List tracks = []; - List playlists = []; - for (MapEntry page - in snapshot.data?.asMap().entries ?? []) { - for (var item in page.value.items ?? []) { - if (item is AlbumSimple) { - albums.add(item); - } else if (item is PlaylistSimple) { - playlists.add(item); - } else if (item is Artist) { - artists.add(item); - } else if (item is Track) { - tracks.add(item); + FutureBuilder>( + future: searchTerm.value.isNotEmpty + ? spotify.search.get(searchTerm.value).first(10) + : null, + builder: (context, snapshot) { + if (!snapshot.hasData && searchTerm.value.isNotEmpty) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } else if (!snapshot.hasData && searchTerm.value.isEmpty) { + return Container(); + } + Playback playback = ref.watch(playbackProvider); + List albums = []; + List artists = []; + List tracks = []; + List playlists = []; + for (MapEntry page + in snapshot.data?.asMap().entries ?? []) { + for (var item in page.value.items ?? []) { + if (item is AlbumSimple) { + albums.add(item); + } else if (item is PlaylistSimple) { + playlists.add(item); + } else if (item is Artist) { + artists.add(item); + } else if (item is Track) { + tracks.add(item); + } } } - } - return Expanded( - child: SingleChildScrollView( - child: Padding( - padding: - const EdgeInsets.symmetric(vertical: 8, horizontal: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (tracks.isNotEmpty) - Text( - "Songs", - style: Theme.of(context).textTheme.headline5, - ), - ...tracks.asMap().entries.map((track) { - String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; - return TrackTile( - playback, - track: track, - duration: duration, - thumbnailUrl: - imageToUrlString(track.value.album?.images), - onTrackPlayButtonPressed: (currentTrack) async { - var isPlaylistPlaying = - playback.currentPlaylist?.id != null && - playback.currentPlaylist?.id == - currentTrack.id; - if (!isPlaylistPlaying) { - playback.setCurrentPlaylist = CurrentPlaylist( - tracks: [currentTrack], - id: currentTrack.id!, - name: currentTrack.name!, - thumbnail: imageToUrlString( - currentTrack.album?.images), - ); - playback.setCurrentTrack = currentTrack; - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != - playback.currentTrack?.id) { - playback.setCurrentTrack = currentTrack; - } - await playback.startPlaying(); - }, - ); - }), - if (albums.isNotEmpty) - Text( - "Albums", - style: Theme.of(context).textTheme.headline5, - ), - const SizedBox(height: 10), - Scrollbar( - controller: albumController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + return Expanded( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (tracks.isNotEmpty) + Text( + "Songs", + style: Theme.of(context).textTheme.headline5, + ), + ...tracks.asMap().entries.map((track) { + String duration = + "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + return TrackTile( + playback, + track: track, + duration: duration, + thumbnailUrl: + imageToUrlString(track.value.album?.images), + onTrackPlayButtonPressed: (currentTrack) async { + var isPlaylistPlaying = + playback.currentPlaylist?.id != null && + playback.currentPlaylist?.id == + currentTrack.id; + if (!isPlaylistPlaying) { + playback.setCurrentPlaylist = CurrentPlaylist( + tracks: [currentTrack], + id: currentTrack.id!, + name: currentTrack.name!, + thumbnail: imageToUrlString( + currentTrack.album?.images), + ); + playback.setCurrentTrack = currentTrack; + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != + playback.currentTrack?.id) { + playback.setCurrentTrack = currentTrack; + } + await playback.startPlaying(); + }, + ); + }), + if (albums.isNotEmpty) + Text( + "Albums", + style: Theme.of(context).textTheme.headline5, + ), + const SizedBox(height: 10), + Scrollbar( controller: albumController, - child: Row( - children: albums.map((album) { - return AlbumCard(simpleAlbumToAlbum(album)); - }).toList(), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: albumController, + child: Row( + children: albums.map((album) { + return AlbumCard(simpleAlbumToAlbum(album)); + }).toList(), + ), ), ), - ), - const SizedBox(height: 20), - if (artists.isNotEmpty) - Text( - "Artists", - style: Theme.of(context).textTheme.headline5, - ), - const SizedBox(height: 10), - Scrollbar( - controller: artistController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + const SizedBox(height: 20), + if (artists.isNotEmpty) + Text( + "Artists", + style: Theme.of(context).textTheme.headline5, + ), + const SizedBox(height: 10), + Scrollbar( controller: artistController, - child: Row( - children: artists - .map( - (artist) => Container( - margin: const EdgeInsets.symmetric( - horizontal: 15), - child: ArtistCard(artist), - ), - ) - .toList(), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: artistController, + child: Row( + children: artists + .map( + (artist) => Container( + margin: const EdgeInsets.symmetric( + horizontal: 15), + child: ArtistCard(artist), + ), + ) + .toList(), + ), ), ), - ), - const SizedBox(height: 20), - if (playlists.isNotEmpty) - Text( - "Playlists", - style: Theme.of(context).textTheme.headline5, - ), - const SizedBox(height: 10), - Scrollbar( - scrollbarOrientation: breakpoint > Breakpoints.md - ? ScrollbarOrientation.bottom - : ScrollbarOrientation.top, - controller: playlistController, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + const SizedBox(height: 20), + if (playlists.isNotEmpty) + Text( + "Playlists", + style: Theme.of(context).textTheme.headline5, + ), + const SizedBox(height: 10), + Scrollbar( + scrollbarOrientation: breakpoint > Breakpoints.md + ? ScrollbarOrientation.bottom + : ScrollbarOrientation.top, controller: playlistController, - child: Row( - children: playlists - .map( - (playlist) => PlaylistCard(playlist), - ) - .toList(), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: playlistController, + child: Row( + children: playlists + .map( + (playlist) => PlaylistCard(playlist), + ) + .toList(), + ), ), ), - ), - ], + ], + ), ), ), - ), - ); - }, - ) - ], + ); + }, + ) + ], + ), ), ); } diff --git a/lib/components/Shared/HeartButton.dart b/lib/components/Shared/HeartButton.dart index d9da43b1..e8d2741c 100644 --- a/lib/components/Shared/HeartButton.dart +++ b/lib/components/Shared/HeartButton.dart @@ -3,9 +3,11 @@ import 'package:flutter/material.dart'; class HeartButton extends StatelessWidget { final bool isLiked; final void Function() onPressed; + final IconData? icon; const HeartButton({ required this.isLiked, required this.onPressed, + this.icon, Key? key, }) : super(key: key); @@ -13,7 +15,10 @@ class HeartButton extends StatelessWidget { Widget build(BuildContext context) { return IconButton( icon: Icon( - !isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded, + icon ?? + (!isLiked + ? Icons.favorite_outline_rounded + : Icons.favorite_rounded), color: isLiked ? Colors.green : null, ), onPressed: onPressed, diff --git a/lib/components/Shared/PageWindowTitleBar.dart b/lib/components/Shared/PageWindowTitleBar.dart index c3a0038d..b8fa26f3 100644 --- a/lib/components/Shared/PageWindowTitleBar.dart +++ b/lib/components/Shared/PageWindowTitleBar.dart @@ -70,17 +70,20 @@ class PageWindowTitleBar extends StatelessWidget ); } return WindowTitleBarBox( - child: Row( - children: [ - if (Platform.isMacOS) - SizedBox( - width: MediaQuery.of(context).size.width * 0.045, - ), - if (leading != null) leading!, - Expanded(child: MoveWindow(child: Center(child: center))), - if (!Platform.isMacOS && !Platform.isIOS && !Platform.isAndroid) - const TitleBarActionButtons() - ], + child: Container( + color: Theme.of(context).backgroundColor, + child: Row( + children: [ + if (Platform.isMacOS) + SizedBox( + width: MediaQuery.of(context).size.width * 0.045, + ), + if (leading != null) leading!, + Expanded(child: MoveWindow(child: Center(child: center))), + if (!Platform.isMacOS && !Platform.isIOS && !Platform.isAndroid) + const TitleBarActionButtons() + ], + ), ), ); } diff --git a/lib/components/Shared/TrackTile.dart b/lib/components/Shared/TrackTile.dart new file mode 100644 index 00000000..a42eacf1 --- /dev/null +++ b/lib/components/Shared/TrackTile.dart @@ -0,0 +1,276 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotify/spotify.dart'; +import 'package:spotube/components/Shared/LinkText.dart'; +import 'package:spotube/helpers/artists-to-clickable-artists.dart'; +import 'package:spotube/hooks/useBreakpoints.dart'; +import 'package:spotube/hooks/useForceUpdate.dart'; +import 'package:spotube/models/Logger.dart'; +import 'package:spotube/provider/Auth.dart'; +import 'package:spotube/provider/Playback.dart'; +import 'package:spotube/provider/SpotifyDI.dart'; + +class TrackTile extends HookConsumerWidget { + final Playback playback; + final MapEntry track; + final String duration; + final String? thumbnailUrl; + final void Function(Track currentTrack)? onTrackPlayButtonPressed; + final logger = getLogger(TrackTile); + final bool userPlaylist; + // null playlistId indicates its not inside a playlist + final String? playlistId; + TrackTile( + this.playback, { + required this.track, + required this.duration, + this.playlistId, + this.userPlaylist = false, + this.thumbnailUrl, + this.onTrackPlayButtonPressed, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final breakpoint = useBreakpoints(); + final auth = ref.watch(authProvider); + final spotify = ref.watch(spotifyProvider); + final update = useForceUpdate(); + + final actionFavorite = useCallback((bool isLiked) async { + try { + isLiked + ? await spotify.tracks.me.removeOne(track.value.id!) + : await spotify.tracks.me.saveOne(track.value.id!); + } catch (e, stack) { + logger.e("FavoriteButton.onPressed", e, stack); + } finally { + update(); + } + }, [track.value.id, spotify]); + + final actionRemoveFromPlaylist = useCallback(() async { + if (playlistId == null) return; + return await spotify.playlists.removeTrack(track.value.uri!, playlistId!); + }, [playlistId, spotify, track.value.uri]); + + actionAddToPlaylist() async { + showDialog( + context: context, + builder: (context) { + return FutureBuilder>( + future: spotify.playlists.me.all().then((playlists) async { + final me = await spotify.me.get(); + return playlists.where((playlist) => + playlist.owner?.id != null && + playlist.owner!.id == me.id); + }), + builder: (context, snapshot) { + return HookBuilder(builder: (context) { + final playlistsCheck = useState({}); + return AlertDialog( + title: Text( + "Add `${track.value.name}` to following Playlists"), + titleTextStyle: + Theme.of(context).textTheme.bodyText1?.copyWith( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + actions: [ + TextButton( + child: const Text("Cancel"), + onPressed: () => Navigator.pop(context), + ), + ElevatedButton( + child: const Text("Add"), + onPressed: () async { + final selectedPlaylists = playlistsCheck + .value.entries + .where((entry) => entry.value) + .map((entry) => entry.key); + + await Future.wait( + selectedPlaylists.map( + (playlistId) => spotify.playlists + .addTrack(track.value.uri!, playlistId), + ), + ).then((_) => Navigator.pop(context)); + }, + ) + ], + content: SizedBox( + height: 300, + width: 300, + child: !snapshot.hasData + ? const Center( + child: CircularProgressIndicator.adaptive()) + : ListView.builder( + shrinkWrap: true, + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + final playlist = + snapshot.data!.elementAt(index); + return CheckboxListTile( + title: Text(playlist.name!), + controlAffinity: + ListTileControlAffinity.leading, + value: playlistsCheck.value[playlist.id] ?? + false, + onChanged: (val) { + playlistsCheck.value = { + ...playlistsCheck.value, + playlist.id!: val == true + }; + }, + ); + }, + ), + ), + ); + }); + }); + }); + } + + return Row( + children: [ + SizedBox( + height: 20, + width: 25, + child: Text( + (track.key + 1).toString(), + textAlign: TextAlign.center, + ), + ), + if (thumbnailUrl != null) + Padding( + padding: EdgeInsets.symmetric( + horizontal: breakpoint.isMoreThan(Breakpoints.md) ? 8.0 : 0, + vertical: 8.0, + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(5)), + child: CachedNetworkImage( + placeholder: (context, url) { + return Container( + height: 40, + width: 40, + color: Colors.green[300], + ); + }, + imageUrl: thumbnailUrl!, + maxHeightDiskCache: 40, + maxWidthDiskCache: 40, + ), + ), + ), + IconButton( + icon: Icon( + playback.currentTrack?.id != null && + playback.currentTrack?.id == track.value.id + ? Icons.pause_circle_rounded + : Icons.play_circle_rounded, + color: Theme.of(context).primaryColor, + ), + onPressed: () => onTrackPlayButtonPressed?.call( + track.value, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.value.name ?? "", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: breakpoint.isSm ? 14 : 17, + ), + overflow: TextOverflow.ellipsis, + ), + artistsToClickableArtists(track.value.artists ?? [], + textStyle: TextStyle( + fontSize: + breakpoint.isLessThan(Breakpoints.lg) ? 12 : 14)), + ], + ), + ), + if (breakpoint.isMoreThan(Breakpoints.md)) + Expanded( + child: LinkText( + track.value.album!.name!, + "/album/${track.value.album?.id}", + extra: track.value.album, + overflow: TextOverflow.ellipsis, + ), + ), + if (!breakpoint.isSm) ...[ + const SizedBox(width: 10), + Text(duration), + ], + const SizedBox(width: 10), + if (auth.isLoggedIn) + FutureBuilder( + future: spotify.tracks.me.containsOne(track.value.id!), + builder: (context, snapshot) { + return PopupMenuButton( + icon: const Icon(Icons.more_horiz_rounded), + itemBuilder: (context) { + return [ + PopupMenuItem( + child: Row( + children: const [ + Icon(Icons.add_box_rounded), + SizedBox(width: 10), + Text("Add to Playlist"), + ], + ), + value: "add-playlist", + ), + if (userPlaylist) + PopupMenuItem( + child: Row( + children: const [ + Icon(Icons.remove_circle_outline_rounded), + SizedBox(width: 10), + Text("Remove from Playlist"), + ], + ), + value: "remove-playlist", + ), + PopupMenuItem( + child: Row( + children: [ + Icon(snapshot.data == true + ? Icons.favorite_rounded + : Icons.favorite_border_rounded), + const SizedBox(width: 10), + const Text("Favorite") + ], + ), + value: "favorite", + ) + ]; + }, + onSelected: (value) { + switch (value) { + case "favorite": + actionFavorite(snapshot.data == true); + break; + case "add-playlist": + actionAddToPlaylist(); + break; + case "remove-playlist": + actionRemoveFromPlaylist(); + break; + } + }, + ); + }) + ], + ); + } +} diff --git a/lib/components/Shared/TracksTableView.dart b/lib/components/Shared/TracksTableView.dart index 4f4f31ad..ca2e72e8 100644 --- a/lib/components/Shared/TracksTableView.dart +++ b/lib/components/Shared/TracksTableView.dart @@ -1,24 +1,24 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/Shared/LinkText.dart'; -import 'package:spotube/helpers/artists-to-clickable-artists.dart'; +import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; -import 'package:spotube/hooks/useForceUpdate.dart'; -import 'package:spotube/models/Logger.dart'; -import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Playback.dart'; -import 'package:spotube/provider/SpotifyDI.dart'; class TracksTableView extends HookConsumerWidget { final void Function(Track currentTrack)? onTrackPlayButtonPressed; final List tracks; - const TracksTableView(this.tracks, {Key? key, this.onTrackPlayButtonPressed}) - : super(key: key); + final bool userPlaylist; + final String? playlistId; + const TracksTableView( + this.tracks, { + Key? key, + this.onTrackPlayButtonPressed, + this.userPlaylist = false, + this.playlistId, + }) : super(key: key); @override Widget build(context, ref) { @@ -85,9 +85,11 @@ class TracksTableView extends HookConsumerWidget { "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; return TrackTile( playback, + playlistId: playlistId, track: track, duration: duration, thumbnailUrl: thumbnailUrl, + userPlaylist: userPlaylist, onTrackPlayButtonPressed: onTrackPlayButtonPressed, ); }).toList() @@ -97,242 +99,3 @@ class TracksTableView extends HookConsumerWidget { ); } } - -class TrackTile extends HookConsumerWidget { - final Playback playback; - final MapEntry track; - final String duration; - final String? thumbnailUrl; - final void Function(Track currentTrack)? onTrackPlayButtonPressed; - final logger = getLogger(TrackTile); - TrackTile( - this.playback, { - required this.track, - required this.duration, - this.thumbnailUrl, - this.onTrackPlayButtonPressed, - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context, ref) { - final breakpoint = useBreakpoints(); - final auth = ref.watch(authProvider); - final spotify = ref.watch(spotifyProvider); - final update = useForceUpdate(); - - final actionFavorite = useCallback((bool isLiked) async { - try { - isLiked - ? await spotify.tracks.me.removeOne(track.value.id!) - : await spotify.tracks.me.saveOne(track.value.id!); - } catch (e, stack) { - logger.e("FavoriteButton.onPressed", e, stack); - } finally { - update(); - } - }, [track.value.id, spotify]); - - actionPlaylist() async { - showDialog( - context: context, - builder: (context) { - return FutureBuilder>( - future: spotify.playlists.me.all().then((playlists) async { - final me = await spotify.me.get(); - return playlists.where((playlist) => - playlist.owner?.id != null && - playlist.owner!.id == me.id); - }), - builder: (context, snapshot) { - return HookBuilder(builder: (context) { - final playlistsCheck = useState({}); - return AlertDialog( - title: Text( - "Add `${track.value.name}` to following Playlists"), - titleTextStyle: - Theme.of(context).textTheme.bodyText1?.copyWith( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - actions: [ - TextButton( - child: const Text("Cancel"), - onPressed: () => Navigator.pop(context), - ), - ElevatedButton( - child: const Text("Add"), - onPressed: () async { - final selectedPlaylists = playlistsCheck - .value.entries - .where((entry) => entry.value) - .map((entry) => entry.key); - - await Future.wait( - selectedPlaylists.map( - (playlistId) => spotify.playlists - .addTrack(track.value.uri!, playlistId), - ), - ).then((_) => Navigator.pop(context)); - }, - ) - ], - content: SizedBox( - height: 300, - width: 300, - child: !snapshot.hasData - ? const Center( - child: CircularProgressIndicator.adaptive()) - : ListView.builder( - shrinkWrap: true, - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - final playlist = - snapshot.data!.elementAt(index); - return CheckboxListTile( - title: Text(playlist.name!), - controlAffinity: - ListTileControlAffinity.leading, - value: playlistsCheck.value[playlist.id] ?? - false, - onChanged: (val) { - playlistsCheck.value = { - ...playlistsCheck.value, - playlist.id!: val == true - }; - }, - ); - }, - ), - ), - ); - }); - }); - }); - } - - return Row( - children: [ - SizedBox( - height: 20, - width: 25, - child: Text( - (track.key + 1).toString(), - textAlign: TextAlign.center, - ), - ), - if (thumbnailUrl != null) - Padding( - padding: EdgeInsets.symmetric( - horizontal: breakpoint.isMoreThan(Breakpoints.md) ? 8.0 : 0, - vertical: 8.0, - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(5)), - child: CachedNetworkImage( - placeholder: (context, url) { - return Container( - height: 40, - width: 40, - color: Colors.green[300], - ); - }, - imageUrl: thumbnailUrl!, - maxHeightDiskCache: 40, - maxWidthDiskCache: 40, - ), - ), - ), - IconButton( - icon: Icon( - playback.currentTrack?.id != null && - playback.currentTrack?.id == track.value.id - ? Icons.pause_circle_rounded - : Icons.play_circle_rounded, - color: Theme.of(context).primaryColor, - ), - onPressed: () => onTrackPlayButtonPressed?.call( - track.value, - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - track.value.name ?? "", - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: breakpoint.isSm ? 14 : 17, - ), - overflow: TextOverflow.ellipsis, - ), - artistsToClickableArtists(track.value.artists ?? [], - textStyle: TextStyle( - fontSize: - breakpoint.isLessThan(Breakpoints.lg) ? 12 : 14)), - ], - ), - ), - if (breakpoint.isMoreThan(Breakpoints.md)) - Expanded( - child: LinkText( - track.value.album!.name!, - "/album/${track.value.album?.id}", - extra: track.value.album, - overflow: TextOverflow.ellipsis, - ), - ), - if (!breakpoint.isSm) ...[ - const SizedBox(width: 10), - Text(duration), - ], - const SizedBox(width: 10), - if (auth.isLoggedIn) - FutureBuilder( - future: spotify.tracks.me.containsOne(track.value.id!), - builder: (context, snapshot) { - return PopupMenuButton( - icon: const Icon(Icons.more_horiz_rounded), - itemBuilder: (context) { - return [ - PopupMenuItem( - child: Row( - children: const [ - Icon(Icons.add_box_rounded), - SizedBox(width: 10), - Text("Add to Playlist"), - ], - ), - value: "playlist", - ), - PopupMenuItem( - child: Row( - children: [ - Icon(snapshot.data == true - ? Icons.favorite_rounded - : Icons.favorite_border_rounded), - const SizedBox(width: 10), - const Text("Favorite") - ], - ), - value: "favorite", - ) - ]; - }, - onSelected: (value) { - switch (value) { - case "favorite": - actionFavorite(snapshot.data == true); - break; - case "playlist": - actionPlaylist(); - break; - } - }, - ); - }) - ], - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index 2c31410b..8ee33562 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -119,6 +119,7 @@ class MyApp extends HookConsumerWidget { scaffoldBackgroundColor: Colors.blueGrey[900], dialogBackgroundColor: Colors.blueGrey[800], shadowColor: Colors.black26, + popupMenuTheme: PopupMenuThemeData(color: Colors.blueGrey[800]), buttonTheme: const ButtonThemeData( buttonColor: Colors.green, ),