diff --git a/assets/empty_box.png b/assets/empty_box.png new file mode 100644 index 00000000..24e95b23 Binary files /dev/null and b/assets/empty_box.png differ diff --git a/lib/components/Album/AlbumView.dart b/lib/components/Album/AlbumView.dart index 95cea0d1..a6f927db 100644 --- a/lib/components/Album/AlbumView.dart +++ b/lib/components/Album/AlbumView.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; -import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; -import 'package:spotube/components/Shared/TracksTableView.dart'; +import 'package:spotube/components/Shared/TrackCollectionView.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/simple-track-to-track.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; @@ -41,7 +41,6 @@ class AlbumView extends HookConsumerWidget { Widget build(BuildContext context, ref) { Playback playback = ref.watch(playbackProvider); - final isPlaylistPlaying = playback.currentPlaylist?.id == album.id; final SpotifyApi spotify = ref.watch(spotifyProvider); final Auth auth = ref.watch(authProvider); @@ -49,85 +48,60 @@ class AlbumView extends HookConsumerWidget { final albumSavedSnapshot = ref.watch(albumIsSavedForCurrentUserQuery(album.id!)); - return SafeArea( - child: Scaffold( - body: Column( - children: [ - PageWindowTitleBar( - leading: Row( - children: [ - // nav back - const BackButton(), - // heart playlist - if (auth.isLoggedIn) - albumSavedSnapshot.when( - data: (isSaved) { - return HeartButton( - isLiked: isSaved, - onPressed: () { - (isSaved - ? spotify.me.removeAlbums( - [album.id!], - ) - : spotify.me.saveAlbums( - [album.id!], - )) - .whenComplete(() { - ref.refresh( - albumIsSavedForCurrentUserQuery( - album.id!, - ), - ); - ref.refresh(currentUserAlbumsQuery); - }); - }, - ); - }, - error: (error, _) => Text("Error $error"), - loading: () => const CircularProgressIndicator()), - // play playlist - IconButton( - icon: Icon( - isPlaylistPlaying - ? Icons.stop_rounded - : Icons.play_arrow_rounded, - ), - onPressed: tracksSnapshot.asData?.value != null - ? () => playPlaylist( - playback, - tracksSnapshot.asData!.value.map((trackSmp) { - return simpleTrackToTrack(trackSmp, album); - }).toList(), - ) - : null, - ) - ], - ), - ), - Center( - child: Text(album.name!, - style: Theme.of(context).textTheme.headline4), - ), - tracksSnapshot.when( - data: (data) { - List tracks = data.map((trackSmp) { - return simpleTrackToTrack(trackSmp, album); - }).toList(); - return TracksTableView( - tracks, - onTrackPlayButtonPressed: (currentTrack) => playPlaylist( - playback, - tracks, - currentTrack: currentTrack, - ), + final albumArt = + useMemoized(() => imageToUrlString(album.images), [album.images]); + + return TrackCollectionView( + id: album.id!, + isPlaying: playback.currentPlaylist?.id != null && + playback.currentPlaylist?.id == album.id, + title: album.name!, + titleImage: albumArt, + tracksSnapshot: tracksSnapshot, + album: album, + onPlay: ([track]) { + if (tracksSnapshot.asData?.value != null) { + playPlaylist( + playback, + tracksSnapshot.asData!.value + .map((track) => simpleTrackToTrack(track, album)) + .toList(), + currentTrack: track, + ); + } + }, + onShare: () { + Clipboard.setData( + ClipboardData(text: "https://open.spotify.com/album/${album.id}"), + ); + }, + heartBtn: auth.isLoggedIn + ? albumSavedSnapshot.when( + data: (isSaved) { + return HeartButton( + isLiked: isSaved, + onPressed: () { + (isSaved + ? spotify.me.removeAlbums( + [album.id!], + ) + : spotify.me.saveAlbums( + [album.id!], + )) + .whenComplete(() { + ref.refresh( + albumIsSavedForCurrentUserQuery( + album.id!, + ), + ); + ref.refresh(currentUserAlbumsQuery); + }); + }, ); }, error: (error, _) => Text("Error $error"), - loading: () => const ShimmerTrackTile(), - ), - ], - ), - ), + loading: () => const CircularProgressIndicator()) + : null, ); } } diff --git a/lib/components/Category/CategoryCard.dart b/lib/components/Category/CategoryCard.dart index a92e98ee..88f833b3 100644 --- a/lib/components/Category/CategoryCard.dart +++ b/lib/components/Category/CategoryCard.dart @@ -5,6 +5,7 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/LoaderShimmers/ShimmerPlaybuttonCard.dart'; import 'package:spotube/components/Playlist/PlaylistCard.dart'; +import 'package:spotube/components/Shared/NotFound.dart'; import 'package:spotube/hooks/usePaginatedFutureProvider.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; @@ -72,6 +73,9 @@ class CategoryCard extends HookConsumerWidget { scrollController: scrollController, scrollDirection: Axis.horizontal, builderDelegate: PagedChildBuilderDelegate( + noItemsFoundIndicatorBuilder: (context) { + return const NotFound(); + }, firstPageProgressIndicatorBuilder: (context) { return const ShimmerPlaybuttonCard(); }, diff --git a/lib/components/Home/Sidebar.dart b/lib/components/Home/Sidebar.dart index ae67b877..e0593dce 100644 --- a/lib/components/Home/Sidebar.dart +++ b/lib/components/Home/Sidebar.dart @@ -3,11 +3,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:flutter/material.dart'; -import 'package:spotify/spotify.dart' hide Image; import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/models/sideBarTiles.dart'; -import 'package:spotube/provider/SpotifyDI.dart'; +import 'package:spotube/provider/SpotifyRequests.dart'; class Sidebar extends HookConsumerWidget { final int selectedIndex; @@ -36,7 +35,7 @@ class Sidebar extends HookConsumerWidget { final breakpoints = useBreakpoints(); if (breakpoints.isSm) return Container(); final extended = useState(false); - final SpotifyApi spotify = ref.watch(spotifyProvider); + final meSnapshot = ref.watch(currentUserQuery); useEffect(() { if (breakpoints.isMd && extended.value) { @@ -78,11 +77,10 @@ class Sidebar extends HookConsumerWidget { ]), ) : _buildSmallLogo(), - trailing: FutureBuilder( - future: spotify.me.get(), - builder: (context, snapshot) { - final avatarImg = imageToUrlString(snapshot.data?.images, - index: (snapshot.data?.images?.length ?? 1) - 1); + trailing: meSnapshot.when( + data: (data) { + final avatarImg = imageToUrlString(data.images, + index: (data.images?.length ?? 1) - 1); return extended.value ? Padding( padding: const EdgeInsets.all(16), @@ -97,7 +95,7 @@ class Sidebar extends HookConsumerWidget { ), const SizedBox(width: 10), Text( - snapshot.data?.displayName ?? "Guest", + data.displayName ?? "Guest", style: const TextStyle( fontWeight: FontWeight.bold, ), @@ -116,6 +114,8 @@ class Sidebar extends HookConsumerWidget { ), ); }, + error: (e, _) => Text("Error $e"), + loading: () => const CircularProgressIndicator(), ), ); } diff --git a/lib/components/Library/UserAlbums.dart b/lib/components/Library/UserAlbums.dart index 7c89c2d5..87bd3413 100644 --- a/lib/components/Library/UserAlbums.dart +++ b/lib/components/Library/UserAlbums.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' hide Image; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/components/Album/AlbumCard.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerPlaybuttonCard.dart'; import 'package:spotube/helpers/simple-album-to-album.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; @@ -25,7 +26,7 @@ class UserAlbums extends ConsumerWidget { ), ), ), - loading: () => const Center(child: CircularProgressIndicator.adaptive()), + loading: () => const Center(child: ShimmerPlaybuttonCard(count: 7)), error: (_, __) => const Text("Failure is the pillar of success"), ); } diff --git a/lib/components/Library/UserPlaylists.dart b/lib/components/Library/UserPlaylists.dart index a3ca21d4..49b1dca1 100644 --- a/lib/components/Library/UserPlaylists.dart +++ b/lib/components/Library/UserPlaylists.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart' hide Image; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerPlaybuttonCard.dart'; import 'package:spotube/components/Playlist/PlaylistCard.dart'; import 'package:spotube/components/Playlist/PlaylistCreateDialog.dart'; import 'package:spotube/provider/SpotifyRequests.dart'; @@ -13,8 +14,7 @@ class UserPlaylists extends ConsumerWidget { final playlists = ref.watch(currentUserPlaylistsQuery); return playlists.when( - loading: () => - const Center(child: CircularProgressIndicator.adaptive()), + loading: () => const Center(child: ShimmerPlaybuttonCard(count: 7)), data: (data) { Image image = Image(); image.height = 300; diff --git a/lib/components/Player/PlayerOverlay.dart b/lib/components/Player/PlayerOverlay.dart index 776b9966..6526b602 100644 --- a/lib/components/Player/PlayerOverlay.dart +++ b/lib/components/Player/PlayerOverlay.dart @@ -37,56 +37,65 @@ class PlayerOverlay extends HookConsumerWidget { right: (breakpoint.isMd ? 10 : 5), left: (breakpoint.isSm ? 5 : 80), bottom: (breakpoint.isSm ? 63 : 10), - child: AnimatedContainer( - duration: const Duration(milliseconds: 500), - width: MediaQuery.of(context).size.width, - height: 50, - decoration: BoxDecoration( - color: paletteColor.color, - borderRadius: BorderRadius.circular(5), - ), - child: Material( - type: MaterialType.transparency, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => GoRouter.of(context).push("/player"), - child: PlayerTrackDetails( - albumArt: albumArt, - color: paletteColor.bodyTextColor, + child: GestureDetector( + onVerticalDragEnd: (details) { + int sensitivity = 8; + if (details.primaryVelocity != null && + details.primaryVelocity! < -sensitivity) { + GoRouter.of(context).push("/player"); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 500), + width: MediaQuery.of(context).size.width, + height: 50, + decoration: BoxDecoration( + color: paletteColor.color, + borderRadius: BorderRadius.circular(5), + ), + child: Material( + type: MaterialType.transparency, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => GoRouter.of(context).push("/player"), + child: PlayerTrackDetails( + albumArt: albumArt, + color: paletteColor.bodyTextColor, + ), ), ), ), - ), - Row( - children: [ - IconButton( - icon: const Icon(Icons.skip_previous_rounded), + Row( + children: [ + IconButton( + icon: const Icon(Icons.skip_previous_rounded), + color: paletteColor.bodyTextColor, + onPressed: () { + onPrevious(); + }), + IconButton( + icon: Icon( + playback.isPlaying + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), color: paletteColor.bodyTextColor, - onPressed: () { - onPrevious(); - }), - IconButton( - icon: Icon( - playback.isPlaying - ? Icons.pause_rounded - : Icons.play_arrow_rounded, + onPressed: _playOrPause, ), - color: paletteColor.bodyTextColor, - onPressed: _playOrPause, - ), - IconButton( - icon: const Icon(Icons.skip_next_rounded), - onPressed: () => onNext(), - color: paletteColor.bodyTextColor, - ), - ], - ), - ], + IconButton( + icon: const Icon(Icons.skip_next_rounded), + onPressed: () => onNext(), + color: paletteColor.bodyTextColor, + ), + ], + ), + ], + ), ), ), ), diff --git a/lib/components/Player/PlayerView.dart b/lib/components/Player/PlayerView.dart index d69c07ee..f4d35317 100644 --- a/lib/components/Player/PlayerView.dart +++ b/lib/components/Player/PlayerView.dart @@ -74,7 +74,7 @@ class PlayerView extends HookConsumerWidget { child: Scaffold( appBar: const PageWindowTitleBar( leading: BackButton(), - transparent: true, + backgroundColor: Colors.transparent, ), backgroundColor: paletteColor.color, body: Column( diff --git a/lib/components/Playlist/PlaylistView.dart b/lib/components/Playlist/PlaylistView.dart index 43730971..25e4f744 100644 --- a/lib/components/Playlist/PlaylistView.dart +++ b/lib/components/Playlist/PlaylistView.dart @@ -1,12 +1,12 @@ import 'dart:convert'; import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart'; import 'package:spotube/components/Shared/HeartButton.dart'; -import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; -import 'package:spotube/components/Shared/TracksTableView.dart'; +import 'package:spotube/components/Shared/TrackCollectionView.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; +import 'package:spotube/hooks/usePaletteColor.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Auth.dart'; @@ -53,118 +53,90 @@ class PlaylistView extends HookConsumerWidget { final meSnapshot = ref.watch(currentUserQuery); final tracksSnapshot = ref.watch(playlistTracksQuery(playlist.id!)); - return SafeArea( - child: Scaffold( - body: Column( - children: [ - PageWindowTitleBar( - leading: Row( - children: [ - // nav back - const BackButton(), - // heart playlist - if (auth.isLoggedIn && playlist.id != "user-liked-tracks") - meSnapshot.when( - data: (me) { - final query = playlistIsFollowedQuery(jsonEncode( - {"playlistId": playlist.id, "userId": me.id!})); - final followingSnapshot = ref.watch(query); + final titleImage = + useMemoized(() => imageToUrlString(playlist.images), [playlist.images]); - return followingSnapshot.when( - data: (isFollowing) { - return HeartButton( - isLiked: isFollowing, - icon: playlist.owner?.id != null && - me.id == playlist.owner?.id - ? Icons.delete_outline_rounded - : null, - onPressed: () async { - try { - isFollowing - ? spotify.playlists - .unfollowPlaylist(playlist.id!) - : spotify.playlists - .followPlaylist(playlist.id!); - } catch (e, stack) { - logger.e("FollowButton.onPressed", e, stack); - } finally { - ref.refresh(query); - ref.refresh(currentUserPlaylistsQuery); - } - }, - ); - }, - error: (error, _) => Text("Error $error"), - loading: () => const CircularProgressIndicator(), - ); - }, - error: (error, _) => Text("Error $error"), - loading: () => const CircularProgressIndicator(), - ), + final color = usePaletteGenerator( + context, + titleImage, + ).dominantColor; - if (playlist.id != "user-liked-tracks") - IconButton( - icon: const Icon(Icons.share_rounded), - onPressed: () { - 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, - ), - ), - ); - }); - }, - ), - // play playlist - IconButton( - icon: Icon( - isPlaylistPlaying - ? Icons.stop_rounded - : Icons.play_arrow_rounded, - ), - onPressed: tracksSnapshot.asData?.value != null - ? () => playPlaylist( - playback, - tracksSnapshot.asData!.value, - ) - : null, - ) - ], + return TrackCollectionView( + id: playlist.id!, + isPlaying: isPlaylistPlaying, + title: playlist.name!, + titleImage: titleImage, + tracksSnapshot: tracksSnapshot, + description: playlist.description, + isOwned: playlist.owner?.id != null && + playlist.owner!.id == meSnapshot.asData?.value.id, + onPlay: ([track]) { + if (tracksSnapshot.asData?.value != null) { + playPlaylist( + playback, + tracksSnapshot.asData!.value, + currentTrack: track, + ); + } + }, + showShare: playlist.id != "user-liked-tracks", + 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, ), ), - Center( - child: Text(playlist.name!, - style: Theme.of(context).textTheme.headline4), - ), - tracksSnapshot.when( - data: (tracks) { - return TracksTableView( - tracks, - onTrackPlayButtonPressed: (currentTrack) => playPlaylist( - playback, - tracks, - currentTrack: currentTrack, - ), - playlistId: playlist.id, - userPlaylist: playlist.owner?.id != null && - playlist.owner!.id == meSnapshot.asData?.value.id, + ); + }); + }, + heartBtn: (auth.isLoggedIn && playlist.id != "user-liked-tracks" + ? meSnapshot.when( + data: (me) { + final query = playlistIsFollowedQuery( + jsonEncode({"playlistId": playlist.id, "userId": me.id!})); + final followingSnapshot = ref.watch(query); + + return followingSnapshot.when( + data: (isFollowing) { + return HeartButton( + isLiked: isFollowing, + color: color?.titleTextColor, + icon: playlist.owner?.id != null && + me.id == playlist.owner?.id + ? Icons.delete_outline_rounded + : null, + onPressed: () async { + try { + isFollowing + ? await spotify.playlists + .unfollowPlaylist(playlist.id!) + : await spotify.playlists + .followPlaylist(playlist.id!); + } catch (e, stack) { + logger.e("FollowButton.onPressed", e, stack); + } finally { + ref.refresh(query); + ref.refresh(currentUserPlaylistsQuery); + } + }, + ); + }, + error: (error, _) => Text("Error $error"), + loading: () => const CircularProgressIndicator(), ); }, error: (error, _) => Text("Error $error"), - loading: () => const ShimmerTrackTile(), - ), - ], - ), - ), + loading: () => const CircularProgressIndicator(), + ) + : null), ); } } diff --git a/lib/components/Shared/HeartButton.dart b/lib/components/Shared/HeartButton.dart index 1d177040..10ea3204 100644 --- a/lib/components/Shared/HeartButton.dart +++ b/lib/components/Shared/HeartButton.dart @@ -4,9 +4,11 @@ class HeartButton extends StatelessWidget { final bool isLiked; final void Function() onPressed; final IconData? icon; + final Color? color; const HeartButton({ required this.isLiked, required this.onPressed, + this.color, this.icon, Key? key, }) : super(key: key); @@ -19,7 +21,7 @@ class HeartButton extends StatelessWidget { (!isLiked ? Icons.favorite_outline_rounded : Icons.favorite_rounded), - color: isLiked ? Theme.of(context).primaryColor : null, + color: isLiked ? Theme.of(context).primaryColor : color, ), onPressed: onPressed, ); diff --git a/lib/components/Shared/NotFound.dart b/lib/components/Shared/NotFound.dart new file mode 100644 index 00000000..05b143d1 --- /dev/null +++ b/lib/components/Shared/NotFound.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class NotFound extends StatelessWidget { + const NotFound({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + height: 150, + width: 150, + child: Image.asset("assets/empty_box.png"), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Nothing found", style: Theme.of(context).textTheme.headline6), + Text( + "The box is empty", + style: Theme.of(context).textTheme.subtitle1, + ), + ], + ), + ], + ); + } +} diff --git a/lib/components/Shared/PageWindowTitleBar.dart b/lib/components/Shared/PageWindowTitleBar.dart index aaf1bf86..10a3225d 100644 --- a/lib/components/Shared/PageWindowTitleBar.dart +++ b/lib/components/Shared/PageWindowTitleBar.dart @@ -4,7 +4,11 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart'; class TitleBarActionButtons extends StatelessWidget { - const TitleBarActionButtons({Key? key}) : super(key: key); + final Color? color; + const TitleBarActionButtons({ + Key? key, + this.color, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -18,7 +22,10 @@ class TitleBarActionButtons extends StatelessWidget { foregroundColor: MaterialStateProperty.all(Theme.of(context).iconTheme.color), ), - child: const Icon(Icons.minimize_rounded)), + child: Icon( + Icons.minimize_rounded, + color: color, + )), TextButton( onPressed: () async { appWindow.maximizeOrRestore(); @@ -27,14 +34,14 @@ class TitleBarActionButtons extends StatelessWidget { foregroundColor: MaterialStateProperty.all(Theme.of(context).iconTheme.color), ), - child: const Icon(Icons.crop_square_rounded)), + child: Icon(Icons.crop_square_rounded, color: color)), TextButton( onPressed: () { appWindow.close(); }, style: ButtonStyle( - foregroundColor: - MaterialStateProperty.all(Theme.of(context).iconTheme.color), + foregroundColor: MaterialStateProperty.all( + color ?? Theme.of(context).iconTheme.color), overlayColor: MaterialStateProperty.all(Colors.redAccent), ), child: const Icon( @@ -49,12 +56,14 @@ class PageWindowTitleBar extends StatelessWidget implements PreferredSizeWidget { final Widget? leading; final Widget? center; - final bool transparent; + final Color? backgroundColor; + final Color? foregroundColor; const PageWindowTitleBar({ Key? key, this.leading, this.center, - this.transparent = false, + this.backgroundColor, + this.foregroundColor, }) : super(key: key); @override Size get preferredSize => Size.fromHeight( @@ -76,7 +85,7 @@ class PageWindowTitleBar extends StatelessWidget } return WindowTitleBarBox( child: Container( - color: !transparent ? Theme.of(context).scaffoldBackgroundColor : null, + color: backgroundColor, child: Row( children: [ if (Platform.isMacOS) @@ -86,7 +95,7 @@ class PageWindowTitleBar extends StatelessWidget if (leading != null) leading!, Expanded(child: MoveWindow(child: Center(child: center))), if (!Platform.isMacOS && !Platform.isIOS && !Platform.isAndroid) - const TitleBarActionButtons() + TitleBarActionButtons(color: foregroundColor) ], ), ), diff --git a/lib/components/Shared/TrackCollectionView.dart b/lib/components/Shared/TrackCollectionView.dart new file mode 100644 index 00000000..9b125450 --- /dev/null +++ b/lib/components/Shared/TrackCollectionView.dart @@ -0,0 +1,240 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart'; +import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; +import 'package:spotube/components/Shared/TracksTableView.dart'; +import 'package:spotube/helpers/simple-track-to-track.dart'; +import 'package:spotube/hooks/usePaletteColor.dart'; +import 'package:spotube/models/Logger.dart'; +import 'package:flutter/material.dart'; +import 'package:spotify/spotify.dart'; + +class TrackCollectionView extends HookConsumerWidget { + final logger = getLogger(TrackCollectionView); + final String id; + final String title; + final String? description; + final AsyncValue> tracksSnapshot; + final String titleImage; + final bool isPlaying; + final void Function([Track? currentTrack]) onPlay; + final void Function() onShare; + final Widget? heartBtn; + final AlbumSimple? album; + + final bool showShare; + final bool isOwned; + TrackCollectionView({ + required this.title, + required this.id, + required this.tracksSnapshot, + required this.titleImage, + required this.isPlaying, + required this.onPlay, + required this.onShare, + this.heartBtn, + this.album, + this.description, + this.showShare = true, + this.isOwned = false, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final color = usePaletteGenerator( + context, + titleImage, + ).dominantColor; + + final List buttons = [ + if (showShare) + IconButton( + icon: Icon( + Icons.share_rounded, + color: color?.titleTextColor, + ), + onPressed: onShare, + ), + if (heartBtn != null) heartBtn!, + + // play playlist + Container( + margin: const EdgeInsets.symmetric(vertical: 10), + child: ElevatedButton( + style: ButtonStyle( + backgroundColor: + MaterialStateProperty.all(Theme.of(context).primaryColor), + shape: MaterialStateProperty.all( + const CircleBorder(), + ), + ), + child: Icon( + isPlaying ? Icons.stop_rounded : Icons.play_arrow_rounded, + color: Theme.of(context).backgroundColor, + ), + onPressed: tracksSnapshot.asData?.value != null ? onPlay : null, + ), + ), + ]; + + final controller = useScrollController(); + + final collapsed = useState(false); + + useEffect(() { + listener() { + if (controller.position.pixels >= 400 && !collapsed.value) { + collapsed.value = true; + } else if (controller.position.pixels < 400 && collapsed.value) { + collapsed.value = false; + } + } + + controller.addListener(listener); + + return () => controller.removeListener(listener); + }, [collapsed.value]); + + return SafeArea( + child: Scaffold( + appBar: PageWindowTitleBar( + backgroundColor: + tracksSnapshot.asData?.value != null ? color?.color : null, + foregroundColor: tracksSnapshot.asData?.value != null + ? color?.titleTextColor + : null, + leading: Row( + children: [ + BackButton( + color: tracksSnapshot.asData?.value != null + ? color?.titleTextColor + : null, + ) + ], + ), + ), + body: tracksSnapshot.when( + data: (tracks) { + return CustomScrollView( + controller: controller, + slivers: [ + SliverAppBar( + actions: collapsed.value ? buttons : null, + floating: false, + pinned: true, + expandedHeight: 400, + automaticallyImplyLeading: false, + primary: true, + title: collapsed.value + ? Text( + title, + style: + Theme.of(context).textTheme.headline4?.copyWith( + color: color?.titleTextColor, + fontWeight: FontWeight.w600, + ), + ) + : null, + backgroundColor: color?.color.withOpacity(0.8), + flexibleSpace: LayoutBuilder(builder: (context, constrains) { + return FlexibleSpaceBar( + background: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + color?.color ?? Colors.transparent, + Theme.of(context).canvasColor, + ], + 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, + vertical: 20, + ), + child: Wrap( + spacing: 20, + runSpacing: 20, + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + Container( + constraints: + const BoxConstraints(maxHeight: 200), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: CachedNetworkImage( + imageUrl: titleImage, + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: Theme.of(context) + .textTheme + .headline4 + ?.copyWith( + color: color?.titleTextColor, + fontWeight: FontWeight.w600, + ), + ), + if (description != null) + Text( + description!, + style: Theme.of(context) + .textTheme + .bodyLarge + ?.copyWith( + color: color?.bodyTextColor, + ), + maxLines: 2, + overflow: TextOverflow.fade, + ), + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: buttons, + ), + ], + ) + ], + ), + ), + ), + ), + ); + }), + ), + TracksTableView( + tracks is! List + ? tracks + .map((track) => simpleTrackToTrack(track, album!)) + .toList() + : tracks, + onTrackPlayButtonPressed: onPlay, + playlistId: id, + userPlaylist: isOwned, + ), + ], + ); + }, + error: (error, _) => Text("Error $error"), + loading: () => const ShimmerTrackTile(), + ), + ), + ); + } +} diff --git a/lib/components/Shared/TracksTableView.dart b/lib/components/Shared/TracksTableView.dart index ca2e72e8..9b6f4869 100644 --- a/lib/components/Shared/TracksTableView.dart +++ b/lib/components/Shared/TracksTableView.dart @@ -12,12 +12,15 @@ class TracksTableView extends HookConsumerWidget { final List tracks; final bool userPlaylist; final String? playlistId; + + final Widget? heading; const TracksTableView( this.tracks, { Key? key, this.onTrackPlayButtonPressed, this.userPlaylist = false, this.playlistId, + this.heading, }) : super(key: key); @override @@ -28,10 +31,79 @@ class TracksTableView extends HookConsumerWidget { final breakpoint = useBreakpoints(); - return Expanded( + return SliverList( + delegate: SliverChildListDelegate([ + if (heading != null) heading!, + Row( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + "#", + textAlign: TextAlign.center, + style: tableHeadStyle, + ), + ), + Expanded( + child: Row( + children: [ + Text( + "Title", + style: tableHeadStyle, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + // used alignment of this table-head + if (breakpoint.isMoreThan(Breakpoints.md)) ...[ + const SizedBox(width: 100), + Expanded( + child: Row( + children: [ + Text( + "Album", + overflow: TextOverflow.ellipsis, + style: tableHeadStyle, + ), + ], + ), + ) + ], + if (!breakpoint.isSm) ...[ + const SizedBox(width: 10), + Text("Time", style: tableHeadStyle), + const SizedBox(width: 10), + ], + const SizedBox(width: 40), + ], + ), + ...tracks.asMap().entries.map((track) { + String? thumbnailUrl = imageToUrlString( + track.value.album?.images, + index: (track.value.album?.images?.length ?? 1) - 1, + ); + String duration = + "${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() + ]), + ); + + return Container( + color: Theme.of(context).backgroundColor, child: Scrollbar( child: ListView( children: [ + if (heading != null) heading!, Row( children: [ Padding( diff --git a/lib/hooks/usePaletteColor.dart b/lib/hooks/usePaletteColor.dart index 77a417d4..c1577bc1 100644 --- a/lib/hooks/usePaletteColor.dart +++ b/lib/hooks/usePaletteColor.dart @@ -38,3 +38,30 @@ PaletteColor usePaletteColor( return paletteColor; } + +PaletteGenerator usePaletteGenerator( + BuildContext context, + String imageUrl, +) { + final palette = useState(PaletteGenerator.fromColors([])); + final mounted = useIsMounted(); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { + final newPalette = await PaletteGenerator.fromImageProvider( + CachedNetworkImageProvider( + imageUrl, + cacheKey: imageUrl, + maxHeight: 50, + maxWidth: 50, + ), + ); + if (!mounted()) return; + + palette.value = newPalette; + }); + return null; + }, [imageUrl]); + + return palette.value; +} diff --git a/lib/provider/Auth.dart b/lib/provider/Auth.dart index 36598335..260b794d 100644 --- a/lib/provider/Auth.dart +++ b/lib/provider/Auth.dart @@ -58,7 +58,7 @@ class Auth extends PersistedChangeNotifier { _refreshToken = null; _expiration = null; notifyListeners(); - updatePersistence(); + updatePersistence(clearNullEntries: true); } @override diff --git a/lib/provider/SpotifyRequests.dart b/lib/provider/SpotifyRequests.dart index 12af9a80..ea9d9097 100644 --- a/lib/provider/SpotifyRequests.dart +++ b/lib/provider/SpotifyRequests.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotube/helpers/getLyrics.dart'; +import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/timed-lyrics.dart'; import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/provider/Playback.dart'; @@ -118,9 +119,18 @@ final albumTracksQuery = FutureProvider.family, String>( ); final currentUserQuery = FutureProvider( - (ref) { + (ref) async { final spotify = ref.watch(spotifyProvider); - return spotify.me.get(); + final me = await spotify.me.get(); + if (me.images == null || me.images?.isEmpty == true) { + me.images = [ + Image() + ..height = 50 + ..width = 50 + ..url = imageToUrlString(me.images), + ]; + } + return me; }, );