diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index dcb10469..98cce616 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,5 @@ open_collective: spotube -ko_fi: krtirtho \ No newline at end of file +ko_fi: krtirtho +patreon: krtirtho +custom: + - "https://www.buymeacoffee.com/krtirtho" diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c6aa75f..9235d6c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +# v2.2.1 + +### Improved +- Page transitions defaulted to material you design + +### Bug fixes +- Mini Player flickering on random state updates +- Track More Options not showing when not logged in +- Wrong link to Client ID & Client Secret tutorial in Login page +- Changing preferences in Settings resets the entire Playback + # v2.2.0 ### New diff --git a/README.md b/README.md index 8b2bbe17..41117801 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,13 @@ Following are the features that currently spotube offers: - Synced Lyrics - Downloadable track -Spotube - A lightweight+free Spotify crossplatform-client made with flutter | Product Hunt +# Support this project + +Patreon donate button + +[!["Donate to out Collective"](https://opencollective.com/webpack/donate/button.png?color=blue)](https://opencollective.com/spotube) +[!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/krtirtho) + # Installation @@ -84,23 +90,12 @@ Get the latest nightly builds of Spotube [here](https://nightly.link/KRTirtho/sp step 2 - **MOST IMPORTANT:** Give the app a name & description. Then Edit settings & add `http://localhost:4304/auth/spotify/callback` as **Redirect URI** for the app. Its important for authenticating
- setp-3 + step-3-a + setp-3-b - Click on **SHOW CLIENT SECRET** to reveal the **clientSecret**. Then copy the **clientID**, **clientSecret** & paste in the **Spotube's** respective fields
step-4 -### Setup Genius Lyrics - -- Signup/Login into [genius](https://genius.com/signup) for **lyrics** -- Go To [Genius Developer Portal](https://genius.com/api-clients/new) for creating an API client
- Step 2 -- Generate & copy access token
- Step 3 -- Paste the copied access token in Spotube's Settings
- Step 4 - -> **Note!**: No personal data or any kind of sensitive information won't be collected from spotify. Don't believe? See the code for yourself - # TODO: - [x] Compile, Debug & Build for **MacOS** @@ -151,6 +146,8 @@ Bu why? You can learn about it [here](https://dev.to/krtirtho/choosing-open-sour - [marquee](https://github.com/MarcelGarus/marquee) - ⏩ A Flutter widget that scrolls text infinitely. Provides many customizations including custom scroll directions, durations, curves as well as pauses after every round - [scroll_to_index](https://github.com/quire-io/scroll-to-index) - scroll to index with fixed/variable row height inside Flutter scrollable widget - [package_info_plus](https://github.com/fluttercommunity/plus_plugins/tree/main/packages/) - This Flutter plugin provides an API for querying information about an application package. +- [version](https://github.com/dartninja/version) - A dart library providing a Version class +- [audio_service](https://github.com/ryanheise/audio_service) - Flutter plugin to play audio in the background while the screen is off. # Social handlers @@ -169,4 +166,4 @@ Follow me on [Twitter](https://twitter.com/@krtirtho) for newer updates about th [mac-dlink]: https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-macos-x86_64.dmg [android-dlink]: https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-android-all-arch.apk -[wiki-installation-instructions]: https://github.com/KRTirtho/spotube/wiki/Installation-Instrcutions \ No newline at end of file +[wiki-installation-instructions]: https://github.com/KRTirtho/spotube/wiki/Installation-Instrcutions 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 f9243009..a6f927db 100644 --- a/lib/components/Album/AlbumView.dart +++ b/lib/components/Album/AlbumView.dart @@ -1,9 +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/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'; @@ -40,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); @@ -48,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 CircularProgressIndicator(), - ), - ], - ), - ), + loading: () => const CircularProgressIndicator()) + : null, ); } } diff --git a/lib/components/Artist/ArtistCard.dart b/lib/components/Artist/ArtistCard.dart index d85c238b..6168f683 100644 --- a/lib/components/Artist/ArtistCard.dart +++ b/lib/components/Artist/ArtistCard.dart @@ -1,7 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:marquee/marquee.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Shared/SpotubeMarqueeText.dart'; diff --git a/lib/components/Artist/ArtistProfile.dart b/lib/components/Artist/ArtistProfile.dart index e25a3139..1c7cb382 100644 --- a/lib/components/Artist/ArtistProfile.dart +++ b/lib/components/Artist/ArtistProfile.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Album/AlbumCard.dart'; import 'package:spotube/components/Artist/ArtistCard.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerArtistProfile.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; @@ -14,7 +15,6 @@ import 'package:spotube/helpers/readable-number.dart'; import 'package:spotube/helpers/zero-pad-num-str.dart'; import 'package:spotube/hooks/useBreakpointValue.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; -import 'package:spotube/hooks/useForceUpdate.dart'; import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/Playback.dart'; @@ -49,7 +49,6 @@ class ArtistProfile extends HookConsumerWidget { ); final breakpoint = useBreakpoints(); - final update = useForceUpdate(); final Playback playback = ref.watch(playbackProvider); @@ -66,268 +65,264 @@ class ArtistProfile extends HookConsumerWidget { leading: BackButton(), ), body: artistsSnapshot.when( - data: (data) { - return SingleChildScrollView( - controller: parentScrollController, - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - runAlignment: WrapAlignment.center, - children: [ - const SizedBox(width: 50), - CircleAvatar( - radius: avatarWidth, - backgroundImage: CachedNetworkImageProvider( - imageToUrlString(data.images), - ), + data: (data) { + return SingleChildScrollView( + controller: parentScrollController, + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + const SizedBox(width: 50), + CircleAvatar( + radius: avatarWidth, + backgroundImage: CachedNetworkImageProvider( + imageToUrlString(data.images), ), - Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.blue, - borderRadius: BorderRadius.circular(50)), - child: Text(data.type!.toUpperCase(), - style: chipTextVariant?.copyWith( - color: Colors.white)), - ), - Text( - data.name!, - style: breakpoint.isSm - ? textTheme.headline4 - : textTheme.headline2, - ), - Text( - "${toReadableNumber(data.followers!.total!.toDouble())} followers", - style: breakpoint.isSm - ? textTheme.bodyText1 - : textTheme.headline5, - ), - const SizedBox(height: 20), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - isFollowingSnapshot.when( - data: (isFollowing) { - return OutlinedButton( - onPressed: () async { - try { - isFollowing - ? await spotify.me.unfollow( - FollowingType.artist, - [artistId], - ) - : await spotify.me.follow( - FollowingType.artist, - [artistId], - ); - } catch (e, stack) { - logger.e( - "FollowButton.onPressed", - e, - stack, - ); - } finally { - ref.refresh( - currentUserFollowsArtistQuery( - artistId), - ); - } - }, - child: Text( - isFollowing - ? "Following" - : "Follow", - ), - ); - }, - error: (error, stackTrace) => Container(), - loading: () => - const CircularProgressIndicator - .adaptive()), - IconButton( - icon: const Icon(Icons.share_rounded), - onPressed: () { - Clipboard.setData( - ClipboardData( - text: data.externalUrls?.spotify), - ).then((val) { - ScaffoldMessenger.of(context) - .showSnackBar( - const SnackBar( - width: 300, - behavior: SnackBarBehavior.floating, - content: Text( - "Artist URL copied to clipboard", - textAlign: TextAlign.center, - ), - ), - ); - }); - }, - ) - ], - ) - ], - ), - ), - ], - ), - const SizedBox(height: 50), - topTracksSnapshot.when( - data: (topTracks) { - final isPlaylistPlaying = - playback.currentPlaylist?.id == data.id; - playPlaylist(List tracks, - {Track? currentTrack}) async { - currentTrack ??= tracks.first; - if (!isPlaylistPlaying) { - playback.setCurrentPlaylist = CurrentPlaylist( - tracks: tracks, - id: data.id!, - name: "${data.name!} To Tracks", - thumbnail: imageToUrlString(data.images), - ); - playback.setCurrentTrack = currentTrack; - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playback.currentTrack?.id) { - playback.setCurrentTrack = currentTrack; - } - await playback.startPlaying(); - } - - return Column(children: [ - Row( - children: [ - Text( - "Top Tracks", - style: Theme.of(context).textTheme.headline4, - ), - Container( - margin: - const EdgeInsets.symmetric(horizontal: 5), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(50), - ), - child: IconButton( - icon: Icon(isPlaylistPlaying - ? Icons.stop_rounded - : Icons.play_arrow_rounded), - color: Colors.white, - onPressed: () => - playPlaylist(topTracks.toList()), - ), - ) - ], - ), - ...topTracks.toList().asMap().entries.map((track) { - String duration = - "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; - String? thumbnailUrl = imageToUrlString( - track.value.album?.images, - index: - (track.value.album?.images?.length ?? 1) - - 1); - return TrackTile( - playback, - duration: duration, - track: track, - thumbnailUrl: thumbnailUrl, - onTrackPlayButtonPressed: (currentTrack) => - playPlaylist( - topTracks.toList(), - currentTrack: track.value, - ), - ); - }), - ]); - }, - error: (error, stack) => - Text("Failed to find top tracks $error"), - loading: () => const Center( - child: CircularProgressIndicator.adaptive()), - ), - const SizedBox(height: 50), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - "Albums", - style: Theme.of(context).textTheme.headline4, - ), - TextButton( - child: const Text("See All"), - onPressed: () { - GoRouter.of(context).push( - "/artist-album/$artistId", - extra: data.name ?? "KRTX", - ); - }, - ) - ], - ), - const SizedBox(height: 10), - albums.when( - data: (albums) { - return Scrollbar( - controller: scrollController, - child: SingleChildScrollView( - controller: scrollController, - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: albums.items - ?.map((album) => AlbumCard(album)) - .toList() ?? - [], + ), + Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.blue, + borderRadius: BorderRadius.circular(50)), + child: Text(data.type!.toUpperCase(), + style: chipTextVariant?.copyWith( + color: Colors.white)), ), + Text( + data.name!, + style: breakpoint.isSm + ? textTheme.headline4 + : textTheme.headline2, + ), + Text( + "${toReadableNumber(data.followers!.total!.toDouble())} followers", + style: breakpoint.isSm + ? textTheme.bodyText1 + : textTheme.headline5, + ), + const SizedBox(height: 20), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + isFollowingSnapshot.when( + data: (isFollowing) { + return OutlinedButton( + onPressed: () async { + try { + isFollowing + ? await spotify.me.unfollow( + FollowingType.artist, + [artistId], + ) + : await spotify.me.follow( + FollowingType.artist, + [artistId], + ); + } catch (e, stack) { + logger.e( + "FollowButton.onPressed", + e, + stack, + ); + } finally { + ref.refresh( + currentUserFollowsArtistQuery( + artistId), + ); + } + }, + child: Text( + isFollowing ? "Following" : "Follow", + ), + ); + }, + error: (error, stackTrace) => Container(), + loading: () => + const CircularProgressIndicator + .adaptive()), + IconButton( + icon: const Icon(Icons.share_rounded), + onPressed: () { + Clipboard.setData( + ClipboardData( + text: data.externalUrls?.spotify), + ).then((val) { + ScaffoldMessenger.of(context) + .showSnackBar( + const SnackBar( + width: 300, + behavior: SnackBarBehavior.floating, + content: Text( + "Artist URL copied to clipboard", + textAlign: TextAlign.center, + ), + ), + ); + }); + }, + ) + ], + ) + ], + ), + ), + ], + ), + const SizedBox(height: 50), + topTracksSnapshot.when( + data: (topTracks) { + final isPlaylistPlaying = + playback.currentPlaylist?.id == data.id; + playPlaylist(List tracks, + {Track? currentTrack}) async { + currentTrack ??= tracks.first; + if (!isPlaylistPlaying) { + playback.setCurrentPlaylist = CurrentPlaylist( + tracks: tracks, + id: data.id!, + name: "${data.name!} To Tracks", + thumbnail: imageToUrlString(data.images), + ); + playback.setCurrentTrack = currentTrack; + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playback.currentTrack?.id) { + playback.setCurrentTrack = currentTrack; + } + await playback.startPlaying(); + } + + return Column(children: [ + Row( + children: [ + Text( + "Top Tracks", + style: Theme.of(context).textTheme.headline4, + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(50), + ), + child: IconButton( + icon: Icon(isPlaylistPlaying + ? Icons.stop_rounded + : Icons.play_arrow_rounded), + color: Colors.white, + onPressed: () => + playPlaylist(topTracks.toList()), + ), + ) + ], + ), + ...topTracks.toList().asMap().entries.map((track) { + String duration = + "${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; + String? thumbnailUrl = imageToUrlString( + track.value.album?.images, + index: + (track.value.album?.images?.length ?? 1) - 1); + return TrackTile( + playback, + duration: duration, + track: track, + thumbnailUrl: thumbnailUrl, + onTrackPlayButtonPressed: (currentTrack) => + playPlaylist( + topTracks.toList(), + currentTrack: track.value, + ), + ); + }), + ]); + }, + error: (error, stack) => + Text("Failed to find top tracks $error"), + loading: () => const Center( + child: CircularProgressIndicator.adaptive()), + ), + const SizedBox(height: 50), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Albums", + style: Theme.of(context).textTheme.headline4, + ), + TextButton( + child: const Text("See All"), + onPressed: () { + GoRouter.of(context).push( + "/artist-album/$artistId", + extra: data.name ?? "KRTX", + ); + }, + ) + ], + ), + const SizedBox(height: 10), + albums.when( + data: (albums) { + return Scrollbar( + controller: scrollController, + child: SingleChildScrollView( + controller: scrollController, + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: albums.items + ?.map((album) => AlbumCard(album)) + .toList() ?? + [], ), - ); - }, - error: (error, stackTrack) => - Text("Failed to get Artist albums $error"), - loading: () => const CircularProgressIndicator.adaptive(), - ), - const SizedBox(height: 20), - Text( - "Fans also likes", - style: Theme.of(context).textTheme.headline4, - ), - const SizedBox(height: 10), - relatedArtists.when( - data: (artists) { - return Center( - child: Wrap( - spacing: 20, - runSpacing: 20, - children: artists - .map((artist) => ArtistCard(artist)) - .toList(), - ), - ); - }, - error: (error, stackTrack) => - Text("Failed to get Artist albums $error"), - loading: () => const CircularProgressIndicator.adaptive(), - ), - ], - ), - ); - }, - error: (_, __) => const Text("Life's miserable"), - loading: () => - const Center(child: CircularProgressIndicator.adaptive())), + ), + ); + }, + error: (error, stackTrack) => + Text("Failed to get Artist albums $error"), + loading: () => const CircularProgressIndicator.adaptive(), + ), + const SizedBox(height: 20), + Text( + "Fans also likes", + style: Theme.of(context).textTheme.headline4, + ), + const SizedBox(height: 10), + relatedArtists.when( + data: (artists) { + return Center( + child: Wrap( + spacing: 20, + runSpacing: 20, + children: artists + .map((artist) => ArtistCard(artist)) + .toList(), + ), + ); + }, + error: (error, stackTrack) => + Text("Failed to get Artist albums $error"), + loading: () => const CircularProgressIndicator.adaptive(), + ), + ], + ), + ); + }, + error: (_, __) => const Text("Life's miserable"), + loading: () => const ShimmerArtistProfile(), + ), ), ); } diff --git a/lib/components/Category/CategoryCard.dart b/lib/components/Category/CategoryCard.dart index 8308f2cc..88f833b3 100644 --- a/lib/components/Category/CategoryCard.dart +++ b/lib/components/Category/CategoryCard.dart @@ -3,7 +3,9 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; 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'; @@ -71,6 +73,15 @@ class CategoryCard extends HookConsumerWidget { scrollController: scrollController, scrollDirection: Axis.horizontal, builderDelegate: PagedChildBuilderDelegate( + noItemsFoundIndicatorBuilder: (context) { + return const NotFound(); + }, + firstPageProgressIndicatorBuilder: (context) { + return const ShimmerPlaybuttonCard(); + }, + newPageProgressIndicatorBuilder: (context) { + return const ShimmerPlaybuttonCard(); + }, itemBuilder: (context, playlist, index) { return PlaylistCard(playlist); }, diff --git a/lib/components/Home/Home.dart b/lib/components/Home/Home.dart index b4968d7f..69bfce40 100644 --- a/lib/components/Home/Home.dart +++ b/lib/components/Home/Home.dart @@ -11,6 +11,7 @@ import 'package:spotify/spotify.dart' hide Image, Player, Search; import 'package:spotube/components/Category/CategoryCard.dart'; import 'package:spotube/components/Home/Sidebar.dart'; import 'package:spotube/components/Home/SpotubeNavigationBar.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerCategories.dart'; import 'package:spotube/components/Lyrics/SyncedLyrics.dart'; import 'package:spotube/components/Search/Search.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; @@ -138,6 +139,10 @@ class Home extends HookConsumerWidget { pagingController: pagingController, builderDelegate: PagedChildBuilderDelegate( + firstPageProgressIndicatorBuilder: (_) => + const ShimmerCategories(), + newPageProgressIndicatorBuilder: (_) => + const ShimmerCategories(), itemBuilder: (context, item, index) { return CategoryCard(item); }, 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/LoaderShimmers/ShimmerArtistProfile.dart b/lib/components/LoaderShimmers/ShimmerArtistProfile.dart new file mode 100644 index 00000000..0fa1dbe6 --- /dev/null +++ b/lib/components/LoaderShimmers/ShimmerArtistProfile.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:skeleton_text/skeleton_text.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerTrackTile.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; +import 'package:spotube/hooks/useBreakpointValue.dart'; + +class ShimmerArtistProfile extends HookWidget { + const ShimmerArtistProfile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final shimmerColor = + Theme.of(context).extension()!.shimmerColor!; + final shimmerBackgroundColor = Theme.of(context) + .extension()! + .shimmerBackgroundColor!; + + final avatarWidth = useBreakpointValue( + sm: MediaQuery.of(context).size.width * 0.80, + md: MediaQuery.of(context).size.width * 0.50, + lg: MediaQuery.of(context).size.width * 0.30, + xl: MediaQuery.of(context).size.width * 0.30, + xxl: MediaQuery.of(context).size.width * 0.30, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(20), + child: SkeletonAnimation( + shimmerColor: shimmerColor, + borderRadius: BorderRadius.circular(avatarWidth), + shimmerDuration: 1000, + child: Container( + width: avatarWidth, + height: avatarWidth, + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(avatarWidth), + ), + ), + ), + ), + const SizedBox(width: 10), + const Flexible(child: ShimmerTrackTile()), + ], + ); + } +} diff --git a/lib/components/LoaderShimmers/ShimmerCategories.dart b/lib/components/LoaderShimmers/ShimmerCategories.dart new file mode 100644 index 00000000..7c0d5227 --- /dev/null +++ b/lib/components/LoaderShimmers/ShimmerCategories.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:skeleton_text/skeleton_text.dart'; +import 'package:spotube/components/LoaderShimmers/ShimmerPlaybuttonCard.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; + +class ShimmerCategories extends StatelessWidget { + const ShimmerCategories({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final shimmerColor = + Theme.of(context).extension()!.shimmerColor!; + final shimmerBackgroundColor = Theme.of(context) + .extension()! + .shimmerBackgroundColor!; + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 15), + child: SkeletonAnimation( + shimmerColor: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + width: 150, + height: 15, + decoration: BoxDecoration( + color: shimmerColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + ), + const ShimmerPlaybuttonCard(count: 7), + ], + ), + ); + } +} diff --git a/lib/components/LoaderShimmers/ShimmerPlaybuttonCard.dart b/lib/components/LoaderShimmers/ShimmerPlaybuttonCard.dart new file mode 100644 index 00000000..e15fcc81 --- /dev/null +++ b/lib/components/LoaderShimmers/ShimmerPlaybuttonCard.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:skeleton_text/skeleton_text.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; + +class ShimmerPlaybuttonCard extends StatelessWidget { + final int count; + const ShimmerPlaybuttonCard({Key? key, this.count = 4}) : super(key: key); + + @override + Widget build(BuildContext context) { + final shimmerColor = + Theme.of(context).extension()!.shimmerColor!; + final shimmerBackgroundColor = Theme.of(context) + .extension()! + .shimmerBackgroundColor!; + + final card = Stack( + children: [ + SkeletonAnimation( + shimmerColor: shimmerColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + width: 200, + height: 220, + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + Column( + children: [ + SkeletonAnimation( + shimmerColor: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + width: 200, + height: 180, + decoration: BoxDecoration( + color: shimmerColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + const SizedBox(height: 5), + SkeletonAnimation( + shimmerColor: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + width: 150, + height: 10, + decoration: BoxDecoration( + color: shimmerColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + ], + ), + ], + ); + + return SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + scrollDirection: Axis.horizontal, + child: Row( + children: [ + Row( + children: List.generate( + count, + (_) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: card, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/LoaderShimmers/ShimmerTrackTile.dart b/lib/components/LoaderShimmers/ShimmerTrackTile.dart new file mode 100644 index 00000000..abf45b16 --- /dev/null +++ b/lib/components/LoaderShimmers/ShimmerTrackTile.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:skeleton_text/skeleton_text.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; + +class ShimmerTrackTile extends StatelessWidget { + const ShimmerTrackTile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final shimmerColor = + Theme.of(context).extension()!.shimmerColor!; + final shimmerBackgroundColor = Theme.of(context) + .extension()! + .shimmerBackgroundColor!; + + return Padding( + padding: const EdgeInsets.only(top: 30), + child: ListView.builder( + scrollDirection: Axis.vertical, + physics: const NeverScrollableScrollPhysics(), + itemCount: 5, + shrinkWrap: true, + itemBuilder: (BuildContext context, int index) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + SkeletonAnimation( + shimmerColor: shimmerColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonAnimation( + shimmerColor: shimmerColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + height: 15, + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + SkeletonAnimation( + shimmerColor: shimmerColor, + borderRadius: BorderRadius.circular(20), + shimmerDuration: 1000, + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * .8), + height: 10, + decoration: BoxDecoration( + color: shimmerBackgroundColor, + borderRadius: BorderRadius.circular(10), + ), + margin: const EdgeInsets.only(top: 10), + ), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/components/Lyrics/SyncedLyrics.dart b/lib/components/Lyrics/SyncedLyrics.dart index 74fb57bb..7843fdce 100644 --- a/lib/components/Lyrics/SyncedLyrics.dart +++ b/lib/components/Lyrics/SyncedLyrics.dart @@ -141,7 +141,9 @@ class SyncedLyrics extends HookConsumerWidget { lyricSlice.text, style: TextStyle( // indicating the active state of that lyric slice - color: isActive ? Colors.green : null, + color: isActive + ? Theme.of(context).primaryColor + : null, fontWeight: isActive ? FontWeight.bold : null, fontSize: 30, ), 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/PlayerTrackDetails.dart b/lib/components/Player/PlayerTrackDetails.dart index 34bd9297..eb0b25ab 100644 --- a/lib/components/Player/PlayerTrackDetails.dart +++ b/lib/components/Player/PlayerTrackDetails.dart @@ -30,7 +30,7 @@ class PlayerTrackDetails extends HookConsumerWidget { return Container( height: 50, width: 50, - color: Colors.green[400], + color: Theme.of(context).primaryColor, ); }, ), 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 9db0c82e..25e4f744 100644 --- a/lib/components/Playlist/PlaylistView.dart +++ b/lib/components/Playlist/PlaylistView.dart @@ -1,11 +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/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'; @@ -23,7 +24,7 @@ class PlaylistView extends HookConsumerWidget { playPlaylist(Playback playback, List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; - var isPlaylistPlaying = playback.currentPlaylist?.id != null && + final isPlaylistPlaying = playback.currentPlaylist?.id != null && playback.currentPlaylist?.id == playlist.id; if (!isPlaylistPlaying) { playback.setCurrentPlaylist = CurrentPlaylist( @@ -52,117 +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) - 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; - 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 CircularProgressIndicator(), - ), - ], - ), - ), + ) + : null), ); } } diff --git a/lib/components/Settings/About.dart b/lib/components/Settings/About.dart index 33ed7b88..a4406c43 100644 --- a/lib/components/Settings/About.dart +++ b/lib/components/Settings/About.dart @@ -26,7 +26,7 @@ class About extends HookWidget { final info = usePackageInfo( appName: "Spotube", packageName: "oss.krtirtho.Spotube", - version: "2.2.0"); + version: "2.2.1"); return ListTile( title: const Text("About Spotube"), diff --git a/lib/components/Settings/Settings.dart b/lib/components/Settings/Settings.dart index 438d17be..9a1f9b42 100644 --- a/lib/components/Settings/Settings.dart +++ b/lib/components/Settings/Settings.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotube/components/Settings/About.dart'; import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart'; import 'package:spotube/components/Settings/SettingsHotkeyTile.dart'; @@ -133,22 +132,43 @@ class Settings extends HookConsumerWidget { onTap: pickColorScheme(ColorSchemeType.background), ), const SizedBox(height: 10), - ListTile( - title: - const Text("Market Place (Recommendation Country)"), - horizontalTitleGap: 10, - trailing: DropdownButton( - value: preferences.recommendationMarket, - items: spotifyMarkets - .map((country) => (DropdownMenuItem( - child: Text(country), - value: country, - ))) - .toList(), - onChanged: (value) { - if (value == null) return; - preferences.setRecommendationMarket(value as String); - }, + Padding( + padding: const EdgeInsets.all(15), + child: Wrap( + alignment: WrapAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Market Place", + style: Theme.of(context).textTheme.bodyText1, + ), + Text( + "Recommendation Country", + style: Theme.of(context).textTheme.caption, + ), + ], + ), + const SizedBox(height: 10), + DropdownButton( + value: preferences.recommendationMarket, + items: spotifyMarkets + .map( + (country) => (DropdownMenuItem( + child: Text(country.last), + value: country.first, + )), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + preferences.setRecommendationMarket( + value as String, + ); + }, + ), + ], ), ), ListTile( 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/TrackTile.dart b/lib/components/Shared/TrackTile.dart index 4e418373..ddd968b3 100644 --- a/lib/components/Shared/TrackTile.dart +++ b/lib/components/Shared/TrackTile.dart @@ -175,7 +175,7 @@ class TrackTile extends HookConsumerWidget { return Container( height: 40, width: 40, - color: Colors.green[300], + color: Theme.of(context).primaryColor, ); }, imageUrl: thumbnailUrl!, 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/extensions/ShimmerColorTheme.dart b/lib/extensions/ShimmerColorTheme.dart new file mode 100644 index 00000000..22a1ce84 --- /dev/null +++ b/lib/extensions/ShimmerColorTheme.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +class ShimmerColorTheme extends ThemeExtension { + final Color? shimmerColor; + final Color? shimmerBackgroundColor; + + ShimmerColorTheme({ + this.shimmerBackgroundColor, + this.shimmerColor, + }); + + @override + ThemeExtension copyWith( + {Color? shimmerColor, Color? shimmerBackgroundColor}) { + return ShimmerColorTheme( + shimmerBackgroundColor: + shimmerBackgroundColor ?? this.shimmerBackgroundColor, + shimmerColor: shimmerColor ?? this.shimmerColor, + ); + } + + @override + ThemeExtension lerp( + ThemeExtension? other, double t) { + if (other is! ShimmerColorTheme) { + return this; + } + return ShimmerColorTheme( + shimmerBackgroundColor: + Color.lerp(shimmerBackgroundColor, other.shimmerBackgroundColor, t), + shimmerColor: Color.lerp(shimmerColor, other.shimmerColor, t), + ); + } +} 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/models/CurrentPlaylist.dart b/lib/models/CurrentPlaylist.dart index 11523220..75322941 100644 --- a/lib/models/CurrentPlaylist.dart +++ b/lib/models/CurrentPlaylist.dart @@ -1,5 +1,60 @@ import 'package:spotify/spotify.dart'; +extension AlbumJson on AlbumSimple { + Map toJson() { + return { + "albumType": albumType, + "id": id, + "name": name, + "images": images + ?.map((image) => { + "height": image.height, + "url": image.url, + "width": image.width, + }) + .toList(), + }; + } +} + +extension ArtistJson on ArtistSimple { + Map toJson() { + return { + "href": href, + "id": id, + "name": name, + "type": type, + "uri": uri, + }; + } +} + +extension TrackJson on Track { + Map toJson() { + return { + "album": album?.toJson(), + "artists": artists?.map((artist) => artist.toJson()).toList(), + "availableMarkets": availableMarkets, + "discNumber": discNumber, + "duration": duration.toString(), + "durationMs": durationMs, + "explicit": explicit, + // "externalIds": externalIds, + // "externalUrls": externalUrls, + "href": href, + "id": id, + "isPlayable": isPlayable, + // "linkedFrom": linkedFrom, + "name": name, + "popularity": popularity, + "previewUrl": previewUrl, + "trackNumber": trackNumber, + "type": type, + "uri": uri, + }; + } +} + class CurrentPlaylist { List? _tempTrack; List tracks; @@ -14,6 +69,16 @@ class CurrentPlaylist { required this.thumbnail, }); + static CurrentPlaylist fromJson(Map map) { + return CurrentPlaylist( + id: map["id"], + tracks: List.castFrom( + map["tracks"].map((track) => Track.fromJson(track)).toList()), + name: map["name"], + thumbnail: map["thumbnail"], + ); + } + List get trackIds => tracks.map((e) => e.id!).toList(); bool shuffle() { @@ -35,4 +100,13 @@ class CurrentPlaylist { } return false; } + + Map toJson() { + return { + "id": id, + "name": name, + "tracks": tracks.map((track) => track.toJson()).toList(), + "thumbnail": thumbnail, + }; + } } diff --git a/lib/models/SpotifyMarkets.dart b/lib/models/SpotifyMarkets.dart index e734800c..7bde6bbb 100644 --- a/lib/models/SpotifyMarkets.dart +++ b/lib/models/SpotifyMarkets.dart @@ -1,186 +1,188 @@ +// Country Codes contributed by momobobe + final spotifyMarkets = [ - "AD", - "AE", - "AG", - "AL", - "AM", - "AO", - "AR", - "AT", - "AU", - "AZ", - "BA", - "BB", - "BD", - "BE", - "BF", - "BG", - "BH", - "BI", - "BJ", - "BN", - "BO", - "BR", - "BS", - "BT", - "BW", - "BY", - "BZ", - "CA", - "CD", - "CG", - "CH", - "CI", - "CL", - "CM", - "CO", - "CR", - "CV", - "CW", - "CY", - "CZ", - "DE", - "DJ", - "DK", - "DM", - "DO", - "DZ", - "EC", - "EE", - "EG", - "ES", - "FI", - "FJ", - "FM", - "FR", - "GA", - "GB", - "GD", - "GE", - "GH", - "GM", - "GN", - "GQ", - "GR", - "GT", - "GW", - "GY", - "HK", - "HN", - "HR", - "HT", - "HU", - "ID", - "IE", - "IL", - "IN", - "IQ", - "IS", - "IT", - "JM", - "JO", - "JP", - "KE", - "KG", - "KH", - "KI", - "KM", - "KN", - "KR", - "KW", - "KZ", - "LA", - "LB", - "LC", - "LI", - "LK", - "LR", - "LS", - "LT", - "LU", - "LV", - "LY", - "MA", - "MC", - "MD", - "ME", - "MG", - "MH", - "MK", - "ML", - "MN", - "MO", - "MR", - "MT", - "MU", - "MV", - "MW", - "MX", - "MY", - "MZ", - "NA", - "NE", - "NG", - "NI", - "NL", - "NO", - "NP", - "NR", - "NZ", - "OM", - "PA", - "PE", - "PG", - "PH", - "PK", - "PL", - "PS", - "PT", - "PW", - "PY", - "QA", - "RO", - "RS", - "RU", - "RW", - "SA", - "SB", - "SC", - "SE", - "SG", - "SI", - "SK", - "SL", - "SM", - "SN", - "SR", - "ST", - "SV", - "SZ", - "TD", - "TG", - "TH", - "TJ", - "TL", - "TN", - "TO", - "TR", - "TT", - "TV", - "TW", - "TZ", - "UA", - "UG", - "US", - "UY", - "UZ", - "VC", - "VE", - "VN", - "VU", - "WS", - "XK", - "ZA", - "ZM", - "ZW" + ["AL", "Albania (AL)"], + ["DZ", "Algeria (DZ)"], + ["AD", "Andorra (AD)"], + ["AO", "Angola (AO)"], + ["AG", "Antigua and Barbuda (AG)"], + ["AR", "Argentina (AR)"], + ["AM", "Armenia (AM)"], + ["AU", "Australia (AU)"], + ["AT", "Austria (AT)"], + ["AZ", "Azerbaijan (AZ)"], + ["BH", "Bahrain (BH)"], + ["BD", "Bangladesh (BD)"], + ["BB", "Barbados (BB)"], + ["BY", "Belarus (BY)"], + ["BE", "Belgium (BE)"], + ["BZ", "Belize (BZ)"], + ["BJ", "Benin (BJ)"], + ["BT", "Bhutan (BT)"], + ["BO", "Bolivia (BO)"], + ["BA", "Bosnia and Herzegovina (BA)"], + ["BW", "Botswana (BW)"], + ["BR", "Brazil (BR)"], + ["BN", "Brunei Darussalam (BN)"], + ["BG", "Bulgaria (BG)"], + ["BF", "Burkina Faso (BF)"], + ["BI", "Burundi (BI)"], + ["CV", "Cabo Verde / Cape Verde (CV)"], + ["KH", "Cambodia (KH)"], + ["CM", "Cameroon (CM)"], + ["CA", "Canada (CA)"], + ["TD", "Chad (TD)"], + ["CL", "Chile (CL)"], + ["CO", "Colombia (CO)"], + ["KM", "Comoros (KM)"], + ["CR", "Costa Rica (CR)"], + ["HR", "Croatia (HR)"], + ["CW", "Curaçao (CW)"], + ["CY", "Cyprus (CY)"], + ["CZ", "Czech Republic (CZ)"], + ["CI", "Côte d'Ivoire / Ivory Coast  (CI)"], + ["CD", "Democratic Republic of the Congo (CD)"], + ["DK", "Denmark (DK)"], + ["DJ", "Djibouti (DJ)"], + ["DM", "Dominica (DM)"], + ["DO", "Dominican Republic (DO)"], + ["EC", "Ecuador (EC)"], + ["EG", "Egypt (EG)"], + ["SV", "El Salvador (SV)"], + ["GQ", "Equatorial Guinea (GQ)"], + ["EE", "Estonia (EE)"], + ["SZ", "Eswatini (SZ)"], + ["FJ", "Fiji (FJ)"], + ["FI", "Finland (FI)"], + ["FR", "France (FR)"], + ["GA", "Gabon (GA)"], + ["GE", "Georgia (GE)"], + ["DE", "Germany (DE)"], + ["GH", "Ghana (GH)"], + ["GR", "Greece (GR)"], + ["GD", "Grenada (GD)"], + ["GT", "Guatemala (GT)"], + ["GN", "Guinea (GN)"], + ["GW", "Guinea-Bissau (GW)"], + ["GY", "Guyana (GY)"], + ["HT", "Haiti (HT)"], + ["HN", "Honduras (HN)"], + ["HK", "Hong Kong (HK)"], + ["HU", "Hungary (HU)"], + ["IS", "Iceland (IS)"], + ["IN", "India (IN)"], + ["ID", "Indonesia (ID)"], + ["IQ", "Iraq (IQ)"], + ["IE", "Ireland (IE)"], + ["IL", "Israel (IL)"], + ["IT", "Italy (IT)"], + ["JM", "Jamaica (JM)"], + ["JP", "Japan (JP)"], + ["JO", "Jordan (JO)"], + ["KZ", "Kazakhstan (KZ)"], + ["KE", "Kenya (KE)"], + ["KI", "Kiribati (KI)"], + ["XK", "Kosovo (XK)"], + ["KW", "Kuwait (KW)"], + ["KG", "Kyrgyzstan (KG)"], + ["LA", "Laos (LA)"], + ["LV", "Latvia (LV)"], + ["LB", "Lebanon (LB)"], + ["LS", "Lesotho (LS)"], + ["LR", "Liberia (LR)"], + ["LY", "Libya (LY)"], + ["LI", "Liechtenstein (LI)"], + ["LT", "Lithuania (LT)"], + ["LU", "Luxembourg (LU)"], + ["MO", "Macao / Macau (MO)"], + ["MG", "Madagascar (MG)"], + ["MW", "Malawi (MW)"], + ["MY", "Malaysia (MY)"], + ["MV", "Maldives (MV)"], + ["ML", "Mali (ML)"], + ["MT", "Malta (MT)"], + ["MH", "Marshall Islands (MH)"], + ["MR", "Mauritania (MR)"], + ["MU", "Mauritius (MU)"], + ["MX", "Mexico (MX)"], + ["FM", "Micronesia (FM)"], + ["MD", "Moldova (MD)"], + ["MC", "Monaco (MC)"], + ["MN", "Mongolia (MN)"], + ["ME", "Montenegro (ME)"], + ["MA", "Morocco (MA)"], + ["MZ", "Mozambique (MZ)"], + ["NA", "Namibia (NA)"], + ["NR", "Nauru (NR)"], + ["NP", "Nepal (NP)"], + ["NL", "Netherlands (NL)"], + ["NZ", "New Zealand (NZ)"], + ["NI", "Nicaragua (NI)"], + ["NE", "Niger (NE)"], + ["NG", "Nigeria (NG)"], + ["MK", "North Macedonia (MK)"], + ["NO", "Norway (NO)"], + ["OM", "Oman (OM)"], + ["PK", "Pakistan (PK)"], + ["PW", "Palau (PW)"], + ["PS", "Palestine (PS)"], + ["PA", "Panama (PA)"], + ["PG", "Papua New Guinea (PG)"], + ["PY", "Paraguay (PY)"], + ["PE", "Peru (PE)"], + ["PH", "Philippines (PH)"], + ["PL", "Poland (PL)"], + ["PT", "Portugal (PT)"], + ["QA", "Qatar (QA)"], + ["CG", "Republic of the Congo (CG)"], + ["RO", "Romania (RO)"], + ["RU", "Russia (RU)"], + ["RW", "Rwanda (RW)"], + ["WS", "Samoa (WS)"], + ["SM", "San Marino (SM)"], + ["SA", "Saudi Arabia (SA)"], + ["SN", "Senegal (SN)"], + ["RS", "Serbia (RS)"], + ["SC", "Seychelles (SC)"], + ["SL", "Sierra Leone (SL)"], + ["SG", "Singapore (SG)"], + ["SK", "Slovakia (SK)"], + ["SI", "Slovenia (SI)"], + ["SB", "Solomon Islands (SB)"], + ["ZA", "South Africa (ZA)"], + ["KR", "South Korea (KR)"], + ["ES", "Spain (ES)"], + ["LK", "Sri Lanka (LK)"], + ["VC", "St Vincent and the Grenadines (VC)"], + ["KN", "St. Kitts and Nevis (KN)"], + ["LC", "St. Lucia (LC)"], + ["SR", "Suriname (SR)"], + ["SE", "Sweden (SE)"], + ["CH", "Switzerland (CH)"], + ["ST", "São Tomé and Príncipe (ST)"], + ["TW", "Taiwan (TW)"], + ["TJ", "Tajikistan (TJ)"], + ["TZ", "Tanzania (TZ)"], + ["TH", "Thailand (TH)"], + ["BS", "The Bahamas (BS)"], + ["GM", "The Gambia (GM)"], + ["TL", "Timor-Leste / East Timor (TL)"], + ["TG", "Togo (TG)"], + ["TO", "Tonga (TO)"], + ["TT", "Trinidad and Tobago (TT)"], + ["TN", "Tunisia (TN)"], + ["TR", "Turkey (TR)"], + ["TV", "Tuvalu (TV)"], + ["UG", "Uganda (UG)"], + ["UA", "Ukraine (UA)"], + ["AE", "United Arab Emirates (AE)"], + ["GB", "United Kingdom (GB)"], + ["US", "United States (US)"], + ["UY", "Uruguay (UY)"], + ["UZ", "Uzbekistan (UZ)"], + ["VU", "Vanuatu (VU)"], + ["VE", "Venezuela (VE)"], + ["VN", "Vietnam (VN)"], + ["ZM", "Zambia (ZM)"], + ["Z", "Zimbabwe (ZW)"], ]; 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/Playback.dart b/lib/provider/Playback.dart index ad41ae24..2abcf985 100644 --- a/lib/provider/Playback.dart +++ b/lib/provider/Playback.dart @@ -1,9 +1,10 @@ import 'dart:async'; +import 'dart:convert'; import 'package:audio_service/audio_service.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/helpers/artist-to-string.dart'; import 'package:spotube/helpers/image-to-url-string.dart'; @@ -13,9 +14,10 @@ import 'package:spotube/models/Logger.dart'; import 'package:spotube/provider/UserPreferences.dart'; import 'package:spotube/provider/YouTube.dart'; import 'package:spotube/utils/AudioPlayerHandler.dart'; +import 'package:spotube/utils/PersistedChangeNotifier.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart'; -class Playback extends ChangeNotifier { +class Playback extends PersistedChangeNotifier { AudioSource? _currentAudioSource; final _logger = getLogger(Playback); CurrentPlaylist? _currentPlaylist; @@ -38,13 +40,15 @@ class Playback extends ChangeNotifier { CurrentPlaylist? currentPlaylist, Track? currentTrack, }) : _currentPlaylist = currentPlaylist, - _currentTrack = currentTrack { + _currentTrack = currentTrack, + super() { player.onNextRequest = () { movePlaylistPositionBy(1); }; player.onPreviousRequest = () { movePlaylistPositionBy(-1); }; + _init(); } @@ -52,7 +56,7 @@ class Playback extends ChangeNotifier { StreamSubscription? _positionStream; StreamSubscription? _playingStream; - void _init() { + void _init() async { _playingStream = player.core.playingStream.listen( (playing) { _isPlaying = playing; @@ -119,12 +123,14 @@ class Playback extends ChangeNotifier { _logger.v("[Setting Current Track] ${track.name} - ${track.id}"); _currentTrack = track; notifyListeners(); + updatePersistence(); } set setCurrentPlaylist(CurrentPlaylist playlist) { _logger.v("[Current Playlist Changed] ${playlist.name} - ${playlist.id}"); _currentPlaylist = playlist; notifyListeners(); + updatePersistence(); } void reset() { @@ -135,6 +141,7 @@ class Playback extends ChangeNotifier { _currentPlaylist = null; _currentTrack = null; notifyListeners(); + updatePersistence(clearNullEntries: true); } /// sets the provided id matched track's uri\ @@ -147,6 +154,7 @@ class Playback extends ChangeNotifier { _currentPlaylist!.tracks.indexWhere((element) => element.id == id); if (index == -1) return false; _currentPlaylist!.tracks[index].uri = uri; + updatePersistence(); return _currentPlaylist!.tracks[index].uri == uri; } catch (e) { return false; @@ -170,6 +178,7 @@ class Playback extends ChangeNotifier { duration = null; _currentTrack = track; notifyListeners(); + updatePersistence(); // starts to play the newly entered next/prev track startPlaying(); } @@ -202,6 +211,7 @@ class Playback extends ChangeNotifier { .then((value) async { _currentTrack = track; notifyListeners(); + updatePersistence(); }); // await player.play(); return; @@ -215,6 +225,7 @@ class Playback extends ChangeNotifier { audioQuality: preferences.audioQuality, ); if (setTrackUriById(track.id!, spotubeTrack.ytUri)) { + logger.v("[Track Direct Source] - ${spotubeTrack.ytUri}"); _currentAudioSource = AudioSource.uri(Uri.parse(spotubeTrack.ytUri)); await player.core .setAudioSource( @@ -224,6 +235,7 @@ class Playback extends ChangeNotifier { .then((value) { _currentTrack = spotubeTrack; notifyListeners(); + updatePersistence(); }); // await player.play(); } @@ -246,6 +258,36 @@ class Playback extends ChangeNotifier { notifyListeners(); } } + + @override + FutureOr loadFromLocal(Map map) { + if (map["currentPlaylist"] != null) { + _currentPlaylist = + CurrentPlaylist.fromJson(jsonDecode(map["currentPlaylist"])); + } + if (map["currentTrack"] != null) { + _currentTrack = Track.fromJson(jsonDecode(map["currentTrack"])); + startPlaying().then((_) { + Timer.periodic(const Duration(milliseconds: 100), (timer) { + if (player.core.playing) { + player.pause(); + timer.cancel(); + } + }); + }); + } + } + + @override + FutureOr> toMap() { + return { + "currentPlaylist": currentPlaylist != null + ? jsonEncode(currentPlaylist?.toJson()) + : null, + "currentTrack": + currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null, + }; + } } final playbackProvider = ChangeNotifierProvider((ref) { 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; }, ); diff --git a/lib/themes/dark-theme.dart b/lib/themes/dark-theme.dart index bcf96aac..4bbaf3ed 100644 --- a/lib/themes/dark-theme.dart +++ b/lib/themes/dark-theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; ThemeData darkTheme({ required MaterialColor accentMaterialColor, @@ -7,6 +8,12 @@ ThemeData darkTheme({ return ThemeData( useMaterial3: true, brightness: Brightness.dark, + extensions: [ + ShimmerColorTheme( + shimmerBackgroundColor: backgroundMaterialColor[700], + shimmerColor: backgroundMaterialColor[800], + ) + ], primaryColor: accentMaterialColor, primarySwatch: accentMaterialColor, backgroundColor: backgroundMaterialColor[900], diff --git a/lib/themes/light-theme.dart b/lib/themes/light-theme.dart index fe642365..65bb88cd 100644 --- a/lib/themes/light-theme.dart +++ b/lib/themes/light-theme.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:spotube/extensions/ShimmerColorTheme.dart'; final materialWhite = MaterialColor(Colors.white.value, { 50: Colors.white, 100: Colors.blueGrey[50]!, 200: Colors.white, 300: Colors.white, - 400: Colors.white, + 400: Colors.blueGrey[300]!, 500: Colors.blueGrey, 600: Colors.white, - 700: Colors.white, + 700: Colors.grey[700]!, 800: Colors.white, 900: Colors.white, }); @@ -19,6 +20,12 @@ ThemeData lightTheme({ }) { return ThemeData( useMaterial3: true, + extensions: [ + ShimmerColorTheme( + shimmerBackgroundColor: backgroundMaterialColor[200], + shimmerColor: backgroundMaterialColor[300], + ) + ], primaryColor: accentMaterialColor, primarySwatch: accentMaterialColor, buttonTheme: ButtonThemeData( diff --git a/lib/utils/PersistedChangeNotifier.dart b/lib/utils/PersistedChangeNotifier.dart index 556984c7..d48cb67a 100644 --- a/lib/utils/PersistedChangeNotifier.dart +++ b/lib/utils/PersistedChangeNotifier.dart @@ -37,7 +37,7 @@ abstract class PersistedChangeNotifier extends ChangeNotifier { FutureOr> toMap(); - Future updatePersistence() async { + Future updatePersistence({bool clearNullEntries = false}) async { for (final entry in (await toMap()).entries) { if (entry.value is bool) { await _localStorage.setBool(entry.key, entry.value); @@ -47,6 +47,8 @@ abstract class PersistedChangeNotifier extends ChangeNotifier { await _localStorage.setDouble(entry.key, entry.value); } else if (entry.value is String) { await _localStorage.setString(entry.key, entry.value); + } else if (entry.value == null && clearNullEntries) { + _localStorage.remove(entry.key); } } } diff --git a/pubspec.lock b/pubspec.lock index ab2e6dc3..ec639796 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -702,6 +702,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.1" + skeleton_text: + dependency: "direct main" + description: + name: skeleton_text + url: "https://pub.dartlang.org" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index d4e49719..a25da890 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 2.2.0+10 +version: 2.2.1+11 environment: sdk: ">=2.15.1 <3.0.0" @@ -61,6 +61,7 @@ dependencies: version: ^2.0.0 audio_service: ^0.18.4 hookified_infinite_scroll_pagination: ^0.1.0 + skeleton_text: ^3.0.0 dev_dependencies: flutter_test: