Merge branch 'master' into build

This commit is contained in:
Kingkor Roy Tirtho 2022-06-14 22:16:01 +06:00
commit 56d5f68e8c
41 changed files with 1583 additions and 748 deletions

3
.github/FUNDING.yml vendored
View File

@ -1,2 +1,5 @@
open_collective: spotube open_collective: spotube
ko_fi: krtirtho ko_fi: krtirtho
patreon: krtirtho
custom:
- "https://www.buymeacoffee.com/krtirtho"

View File

@ -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 # v2.2.0
### New ### New

View File

@ -44,7 +44,13 @@ Following are the features that currently spotube offers:
- Synced Lyrics - Synced Lyrics
- Downloadable track - Downloadable track
<a href="https://www.producthunt.com/posts/spotube?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-spotube" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=327965&theme=dark" alt="Spotube - A lightweight+free Spotify crossplatform-client made with flutter | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> # Support this project
<a href="https://patreon.com/krtirtho"><img src="https://img.shields.io/endpoint.svg?url=https://moshef9.wixsite.com/patreon-badge/_functions/badge/?username=krtirtho" alt="Patreon donate button" /> </a>
[!["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 # Installation
@ -84,23 +90,12 @@ Get the latest nightly builds of Spotube [here](https://nightly.link/KRTirtho/sp
<img width='480' alt='step 2' src='https://user-images.githubusercontent.com/61944859/111762507-473f4700-88cb-11eb-91f3-d480e9584883.png'/> <img width='480' alt='step 2' src='https://user-images.githubusercontent.com/61944859/111762507-473f4700-88cb-11eb-91f3-d480e9584883.png'/>
- **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<br/> - **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<br/>
<img width='720' alt='setp-3' src='https://user-images.githubusercontent.com/61944859/111768971-d308a180-88d2-11eb-9108-3e7444cef049.png'/> <img width='720' alt='step-3-a' src='https://user-images.githubusercontent.com/61944859/172991668-fa40f247-1118-4aba-a749-e669b732fa4d.jpg' />
<img width='720' alt='setp-3-b' src='https://user-images.githubusercontent.com/61944859/111768971-d308a180-88d2-11eb-9108-3e7444cef049.png'/>
- Click on **SHOW CLIENT SECRET** to reveal the **clientSecret**. Then copy the **clientID**, **clientSecret** & paste in the **Spotube's** respective fields<br/> - Click on **SHOW CLIENT SECRET** to reveal the **clientSecret**. Then copy the **clientID**, **clientSecret** & paste in the **Spotube's** respective fields<br/>
<img width='480' alt='step-4' src='https://user-images.githubusercontent.com/61944859/111769501-7fe31e80-88d3-11eb-8fc1-f3655dbd4711.png'/> <img width='480' alt='step-4' src='https://user-images.githubusercontent.com/61944859/111769501-7fe31e80-88d3-11eb-8fc1-f3655dbd4711.png'/>
### Setup <b>Genius Lyrics</b>
- 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<br/>
<img width='480' alt='Step 2' src='https://user-images.githubusercontent.com/61944859/158823216-b4942731-c4c5-46c8-8b60-82a372b51cc5.png' />
- Generate & copy access token<br/>
<img width='480' alt='Step 3' src='https://user-images.githubusercontent.com/61944859/158822817-f04da060-3094-4a3b-8ace-a936d0cda8db.png' />
- Paste the copied access token in Spotube's Settings<br/>
<img width='480' alt='Step 4' src='https://user-images.githubusercontent.com/61944859/158823984-17f08534-5c92-41bc-918a-23194aad00f5.png' />
> **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: # TODO:
- [x] Compile, Debug & Build for **MacOS** - [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 - [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 - [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. - [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 # Social handlers

BIN
assets/empty_box.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/HeartButton.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/TrackCollectionView.dart';
import 'package:spotube/components/Shared/TracksTableView.dart';
import 'package:spotube/helpers/image-to-url-string.dart'; import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/simple-track-to-track.dart'; import 'package:spotube/helpers/simple-track-to-track.dart';
import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/CurrentPlaylist.dart';
@ -40,7 +41,6 @@ class AlbumView extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider); Playback playback = ref.watch(playbackProvider);
final isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
final SpotifyApi spotify = ref.watch(spotifyProvider); final SpotifyApi spotify = ref.watch(spotifyProvider);
final Auth auth = ref.watch(authProvider); final Auth auth = ref.watch(authProvider);
@ -48,85 +48,60 @@ class AlbumView extends HookConsumerWidget {
final albumSavedSnapshot = final albumSavedSnapshot =
ref.watch(albumIsSavedForCurrentUserQuery(album.id!)); ref.watch(albumIsSavedForCurrentUserQuery(album.id!));
return SafeArea( final albumArt =
child: Scaffold( useMemoized(() => imageToUrlString(album.images), [album.images]);
body: Column(
children: [ return TrackCollectionView(
PageWindowTitleBar( id: album.id!,
leading: Row( isPlaying: playback.currentPlaylist?.id != null &&
children: [ playback.currentPlaylist?.id == album.id,
// nav back title: album.name!,
const BackButton(), titleImage: albumArt,
// heart playlist tracksSnapshot: tracksSnapshot,
if (auth.isLoggedIn) album: album,
albumSavedSnapshot.when( onPlay: ([track]) {
data: (isSaved) { if (tracksSnapshot.asData?.value != null) {
return HeartButton( playPlaylist(
isLiked: isSaved, playback,
onPressed: () { tracksSnapshot.asData!.value
(isSaved .map((track) => simpleTrackToTrack(track, album))
? spotify.me.removeAlbums( .toList(),
[album.id!], currentTrack: track,
) );
: spotify.me.saveAlbums( }
[album.id!], },
)) onShare: () {
.whenComplete(() { Clipboard.setData(
ref.refresh( ClipboardData(text: "https://open.spotify.com/album/${album.id}"),
albumIsSavedForCurrentUserQuery( );
album.id!, },
), heartBtn: auth.isLoggedIn
); ? albumSavedSnapshot.when(
ref.refresh(currentUserAlbumsQuery); data: (isSaved) {
}); return HeartButton(
}, isLiked: isSaved,
); onPressed: () {
}, (isSaved
error: (error, _) => Text("Error $error"), ? spotify.me.removeAlbums(
loading: () => const CircularProgressIndicator()), [album.id!],
// play playlist )
IconButton( : spotify.me.saveAlbums(
icon: Icon( [album.id!],
isPlaylistPlaying ))
? Icons.stop_rounded .whenComplete(() {
: Icons.play_arrow_rounded, ref.refresh(
), albumIsSavedForCurrentUserQuery(
onPressed: tracksSnapshot.asData?.value != null album.id!,
? () => playPlaylist( ),
playback, );
tracksSnapshot.asData!.value.map((trackSmp) { ref.refresh(currentUserAlbumsQuery);
return simpleTrackToTrack(trackSmp, album); });
}).toList(), },
)
: null,
)
],
),
),
Center(
child: Text(album.name!,
style: Theme.of(context).textTheme.headline4),
),
tracksSnapshot.when(
data: (data) {
List<Track> tracks = data.map((trackSmp) {
return simpleTrackToTrack(trackSmp, album);
}).toList();
return TracksTableView(
tracks,
onTrackPlayButtonPressed: (currentTrack) => playPlaylist(
playback,
tracks,
currentTrack: currentTrack,
),
); );
}, },
error: (error, _) => Text("Error $error"), error: (error, _) => Text("Error $error"),
loading: () => const CircularProgressIndicator(), loading: () => const CircularProgressIndicator())
), : null,
],
),
),
); );
} }
} }

View File

@ -1,7 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:marquee/marquee.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/SpotubeMarqueeText.dart'; import 'package:spotube/components/Shared/SpotubeMarqueeText.dart';

View File

@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumCard.dart'; import 'package:spotube/components/Album/AlbumCard.dart';
import 'package:spotube/components/Artist/ArtistCard.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/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/components/Shared/TrackTile.dart';
import 'package:spotube/helpers/image-to-url-string.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/helpers/zero-pad-num-str.dart';
import 'package:spotube/hooks/useBreakpointValue.dart'; import 'package:spotube/hooks/useBreakpointValue.dart';
import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/hooks/useForceUpdate.dart';
import 'package:spotube/models/CurrentPlaylist.dart'; import 'package:spotube/models/CurrentPlaylist.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
@ -49,7 +49,6 @@ class ArtistProfile extends HookConsumerWidget {
); );
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
final update = useForceUpdate();
final Playback playback = ref.watch(playbackProvider); final Playback playback = ref.watch(playbackProvider);
@ -66,268 +65,264 @@ class ArtistProfile extends HookConsumerWidget {
leading: BackButton(), leading: BackButton(),
), ),
body: artistsSnapshot.when<Widget>( body: artistsSnapshot.when<Widget>(
data: (data) { data: (data) {
return SingleChildScrollView( return SingleChildScrollView(
controller: parentScrollController, controller: parentScrollController,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Wrap( Wrap(
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
runAlignment: WrapAlignment.center, runAlignment: WrapAlignment.center,
children: [ children: [
const SizedBox(width: 50), const SizedBox(width: 50),
CircleAvatar( CircleAvatar(
radius: avatarWidth, radius: avatarWidth,
backgroundImage: CachedNetworkImageProvider( backgroundImage: CachedNetworkImageProvider(
imageToUrlString(data.images), imageToUrlString(data.images),
),
), ),
Padding( ),
padding: const EdgeInsets.all(20), Padding(
child: Column( padding: const EdgeInsets.all(20),
mainAxisSize: MainAxisSize.min, child: Column(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Container( children: [
padding: const EdgeInsets.symmetric( Container(
horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration( horizontal: 10, vertical: 5),
color: Colors.blue, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50)), color: Colors.blue,
child: Text(data.type!.toUpperCase(), borderRadius: BorderRadius.circular(50)),
style: chipTextVariant?.copyWith( child: Text(data.type!.toUpperCase(),
color: Colors.white)), 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<Track> 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() ??
[],
), ),
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<Track> 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"), error: (error, stackTrack) =>
loading: () => const CircularProgressIndicator.adaptive(), Text("Failed to get Artist albums $error"),
), loading: () => const CircularProgressIndicator.adaptive(),
const SizedBox(height: 20), ),
Text( const SizedBox(height: 20),
"Fans also likes", Text(
style: Theme.of(context).textTheme.headline4, "Fans also likes",
), style: Theme.of(context).textTheme.headline4,
const SizedBox(height: 10), ),
relatedArtists.when( const SizedBox(height: 10),
data: (artists) { relatedArtists.when(
return Center( data: (artists) {
child: Wrap( return Center(
spacing: 20, child: Wrap(
runSpacing: 20, spacing: 20,
children: artists runSpacing: 20,
.map((artist) => ArtistCard(artist)) children: artists
.toList(), .map((artist) => ArtistCard(artist))
), .toList(),
); ),
}, );
error: (error, stackTrack) => },
Text("Failed to get Artist albums $error"), error: (error, stackTrack) =>
loading: () => const CircularProgressIndicator.adaptive(), Text("Failed to get Artist albums $error"),
), loading: () => const CircularProgressIndicator.adaptive(),
], ),
), ],
); ),
}, );
error: (_, __) => const Text("Life's miserable"), },
loading: () => error: (_, __) => const Text("Life's miserable"),
const Center(child: CircularProgressIndicator.adaptive())), loading: () => const ShimmerArtistProfile(),
),
), ),
); );
} }

View File

@ -3,7 +3,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:spotify/spotify.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/PlaylistCard.dart';
import 'package:spotube/components/Shared/NotFound.dart';
import 'package:spotube/hooks/usePaginatedFutureProvider.dart'; import 'package:spotube/hooks/usePaginatedFutureProvider.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/provider/SpotifyRequests.dart';
@ -71,6 +73,15 @@ class CategoryCard extends HookConsumerWidget {
scrollController: scrollController, scrollController: scrollController,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
builderDelegate: PagedChildBuilderDelegate<PlaylistSimple>( builderDelegate: PagedChildBuilderDelegate<PlaylistSimple>(
noItemsFoundIndicatorBuilder: (context) {
return const NotFound();
},
firstPageProgressIndicatorBuilder: (context) {
return const ShimmerPlaybuttonCard();
},
newPageProgressIndicatorBuilder: (context) {
return const ShimmerPlaybuttonCard();
},
itemBuilder: (context, playlist, index) { itemBuilder: (context, playlist, index) {
return PlaylistCard(playlist); return PlaylistCard(playlist);
}, },

View File

@ -11,6 +11,7 @@ import 'package:spotify/spotify.dart' hide Image, Player, Search;
import 'package:spotube/components/Category/CategoryCard.dart'; import 'package:spotube/components/Category/CategoryCard.dart';
import 'package:spotube/components/Home/Sidebar.dart'; import 'package:spotube/components/Home/Sidebar.dart';
import 'package:spotube/components/Home/SpotubeNavigationBar.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/Lyrics/SyncedLyrics.dart';
import 'package:spotube/components/Search/Search.dart'; import 'package:spotube/components/Search/Search.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
@ -138,6 +139,10 @@ class Home extends HookConsumerWidget {
pagingController: pagingController, pagingController: pagingController,
builderDelegate: builderDelegate:
PagedChildBuilderDelegate<Category>( PagedChildBuilderDelegate<Category>(
firstPageProgressIndicatorBuilder: (_) =>
const ShimmerCategories(),
newPageProgressIndicatorBuilder: (_) =>
const ShimmerCategories(),
itemBuilder: (context, item, index) { itemBuilder: (context, item, index) {
return CategoryCard(item); return CategoryCard(item);
}, },

View File

@ -3,11 +3,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter/material.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/helpers/image-to-url-string.dart';
import 'package:spotube/hooks/useBreakpoints.dart'; import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/models/sideBarTiles.dart'; import 'package:spotube/models/sideBarTiles.dart';
import 'package:spotube/provider/SpotifyDI.dart'; import 'package:spotube/provider/SpotifyRequests.dart';
class Sidebar extends HookConsumerWidget { class Sidebar extends HookConsumerWidget {
final int selectedIndex; final int selectedIndex;
@ -36,7 +35,7 @@ class Sidebar extends HookConsumerWidget {
final breakpoints = useBreakpoints(); final breakpoints = useBreakpoints();
if (breakpoints.isSm) return Container(); if (breakpoints.isSm) return Container();
final extended = useState(false); final extended = useState(false);
final SpotifyApi spotify = ref.watch(spotifyProvider); final meSnapshot = ref.watch(currentUserQuery);
useEffect(() { useEffect(() {
if (breakpoints.isMd && extended.value) { if (breakpoints.isMd && extended.value) {
@ -78,11 +77,10 @@ class Sidebar extends HookConsumerWidget {
]), ]),
) )
: _buildSmallLogo(), : _buildSmallLogo(),
trailing: FutureBuilder<User>( trailing: meSnapshot.when(
future: spotify.me.get(), data: (data) {
builder: (context, snapshot) { final avatarImg = imageToUrlString(data.images,
final avatarImg = imageToUrlString(snapshot.data?.images, index: (data.images?.length ?? 1) - 1);
index: (snapshot.data?.images?.length ?? 1) - 1);
return extended.value return extended.value
? Padding( ? Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@ -97,7 +95,7 @@ class Sidebar extends HookConsumerWidget {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
snapshot.data?.displayName ?? "Guest", data.displayName ?? "Guest",
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
@ -116,6 +114,8 @@ class Sidebar extends HookConsumerWidget {
), ),
); );
}, },
error: (e, _) => Text("Error $e"),
loading: () => const CircularProgressIndicator(),
), ),
); );
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart' hide Image; import 'package:flutter/material.dart' hide Image;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/components/Album/AlbumCard.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/helpers/simple-album-to-album.dart';
import 'package:spotube/provider/SpotifyRequests.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"), error: (_, __) => const Text("Failure is the pillar of success"),
); );
} }

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart' hide Image; import 'package:flutter/material.dart' hide Image;
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.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/PlaylistCard.dart';
import 'package:spotube/components/Playlist/PlaylistCreateDialog.dart'; import 'package:spotube/components/Playlist/PlaylistCreateDialog.dart';
import 'package:spotube/provider/SpotifyRequests.dart'; import 'package:spotube/provider/SpotifyRequests.dart';
@ -13,8 +14,7 @@ class UserPlaylists extends ConsumerWidget {
final playlists = ref.watch(currentUserPlaylistsQuery); final playlists = ref.watch(currentUserPlaylistsQuery);
return playlists.when( return playlists.when(
loading: () => loading: () => const Center(child: ShimmerPlaybuttonCard(count: 7)),
const Center(child: CircularProgressIndicator.adaptive()),
data: (data) { data: (data) {
Image image = Image(); Image image = Image();
image.height = 300; image.height = 300;

View File

@ -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<ShimmerColorTheme>()!.shimmerColor!;
final shimmerBackgroundColor = Theme.of(context)
.extension<ShimmerColorTheme>()!
.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()),
],
);
}
}

View File

@ -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<ShimmerColorTheme>()!.shimmerColor!;
final shimmerBackgroundColor = Theme.of(context)
.extension<ShimmerColorTheme>()!
.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),
],
),
);
}
}

View File

@ -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<ShimmerColorTheme>()!.shimmerColor!;
final shimmerBackgroundColor = Theme.of(context)
.extension<ShimmerColorTheme>()!
.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,
),
),
),
],
),
);
}
}

View File

@ -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<ShimmerColorTheme>()!.shimmerColor!;
final shimmerBackgroundColor = Theme.of(context)
.extension<ShimmerColorTheme>()!
.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),
),
),
],
),
),
],
),
);
},
),
);
}
}

View File

@ -141,7 +141,9 @@ class SyncedLyrics extends HookConsumerWidget {
lyricSlice.text, lyricSlice.text,
style: TextStyle( style: TextStyle(
// indicating the active state of that lyric slice // 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, fontWeight: isActive ? FontWeight.bold : null,
fontSize: 30, fontSize: 30,
), ),

View File

@ -37,56 +37,65 @@ class PlayerOverlay extends HookConsumerWidget {
right: (breakpoint.isMd ? 10 : 5), right: (breakpoint.isMd ? 10 : 5),
left: (breakpoint.isSm ? 5 : 80), left: (breakpoint.isSm ? 5 : 80),
bottom: (breakpoint.isSm ? 63 : 10), bottom: (breakpoint.isSm ? 63 : 10),
child: AnimatedContainer( child: GestureDetector(
duration: const Duration(milliseconds: 500), onVerticalDragEnd: (details) {
width: MediaQuery.of(context).size.width, int sensitivity = 8;
height: 50, if (details.primaryVelocity != null &&
decoration: BoxDecoration( details.primaryVelocity! < -sensitivity) {
color: paletteColor.color, GoRouter.of(context).push("/player");
borderRadius: BorderRadius.circular(5), }
), },
child: Material( child: AnimatedContainer(
type: MaterialType.transparency, duration: const Duration(milliseconds: 500),
child: Row( width: MediaQuery.of(context).size.width,
mainAxisAlignment: MainAxisAlignment.spaceBetween, height: 50,
children: [ decoration: BoxDecoration(
Expanded( color: paletteColor.color,
child: MouseRegion( borderRadius: BorderRadius.circular(5),
cursor: SystemMouseCursors.click, ),
child: GestureDetector( child: Material(
onTap: () => GoRouter.of(context).push("/player"), type: MaterialType.transparency,
child: PlayerTrackDetails( child: Row(
albumArt: albumArt, mainAxisAlignment: MainAxisAlignment.spaceBetween,
color: paletteColor.bodyTextColor, children: [
Expanded(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => GoRouter.of(context).push("/player"),
child: PlayerTrackDetails(
albumArt: albumArt,
color: paletteColor.bodyTextColor,
),
), ),
), ),
), ),
), Row(
Row( children: [
children: [ IconButton(
IconButton( icon: const Icon(Icons.skip_previous_rounded),
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, color: paletteColor.bodyTextColor,
onPressed: () { onPressed: _playOrPause,
onPrevious();
}),
IconButton(
icon: Icon(
playback.isPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
), ),
color: paletteColor.bodyTextColor, IconButton(
onPressed: _playOrPause, icon: const Icon(Icons.skip_next_rounded),
), onPressed: () => onNext(),
IconButton( color: paletteColor.bodyTextColor,
icon: const Icon(Icons.skip_next_rounded), ),
onPressed: () => onNext(), ],
color: paletteColor.bodyTextColor, ),
), ],
], ),
),
],
), ),
), ),
), ),

View File

@ -30,7 +30,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
return Container( return Container(
height: 50, height: 50,
width: 50, width: 50,
color: Colors.green[400], color: Theme.of(context).primaryColor,
); );
}, },
), ),

View File

@ -74,7 +74,7 @@ class PlayerView extends HookConsumerWidget {
child: Scaffold( child: Scaffold(
appBar: const PageWindowTitleBar( appBar: const PageWindowTitleBar(
leading: BackButton(), leading: BackButton(),
transparent: true, backgroundColor: Colors.transparent,
), ),
backgroundColor: paletteColor.color, backgroundColor: paletteColor.color,
body: Column( body: Column(

View File

@ -1,11 +1,12 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/Shared/HeartButton.dart'; import 'package:spotube/components/Shared/HeartButton.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart'; import 'package:spotube/components/Shared/TrackCollectionView.dart';
import 'package:spotube/components/Shared/TracksTableView.dart';
import 'package:spotube/helpers/image-to-url-string.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/CurrentPlaylist.dart';
import 'package:spotube/models/Logger.dart'; import 'package:spotube/models/Logger.dart';
import 'package:spotube/provider/Auth.dart'; import 'package:spotube/provider/Auth.dart';
@ -23,7 +24,7 @@ class PlaylistView extends HookConsumerWidget {
playPlaylist(Playback playback, List<Track> tracks, playPlaylist(Playback playback, List<Track> tracks,
{Track? currentTrack}) async { {Track? currentTrack}) async {
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
var isPlaylistPlaying = playback.currentPlaylist?.id != null && final isPlaylistPlaying = playback.currentPlaylist?.id != null &&
playback.currentPlaylist?.id == playlist.id; playback.currentPlaylist?.id == playlist.id;
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
playback.setCurrentPlaylist = CurrentPlaylist( playback.setCurrentPlaylist = CurrentPlaylist(
@ -52,117 +53,90 @@ class PlaylistView extends HookConsumerWidget {
final meSnapshot = ref.watch(currentUserQuery); final meSnapshot = ref.watch(currentUserQuery);
final tracksSnapshot = ref.watch(playlistTracksQuery(playlist.id!)); final tracksSnapshot = ref.watch(playlistTracksQuery(playlist.id!));
return SafeArea( final titleImage =
child: Scaffold( useMemoized(() => imageToUrlString(playlist.images), [playlist.images]);
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);
return followingSnapshot.when( final color = usePaletteGenerator(
data: (isFollowing) { context,
return HeartButton( titleImage,
isLiked: isFollowing, ).dominantColor;
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(),
),
IconButton( return TrackCollectionView(
icon: const Icon(Icons.share_rounded), id: playlist.id!,
onPressed: () { isPlaying: isPlaylistPlaying,
final data = title: playlist.name!,
"https://open.spotify.com/playlist/${playlist.id}"; titleImage: titleImage,
Clipboard.setData( tracksSnapshot: tracksSnapshot,
ClipboardData(text: data), description: playlist.description,
).then((_) { isOwned: playlist.owner?.id != null &&
ScaffoldMessenger.of(context).showSnackBar( playlist.owner!.id == meSnapshot.asData?.value.id,
SnackBar( onPlay: ([track]) {
width: 300, if (tracksSnapshot.asData?.value != null) {
behavior: SnackBarBehavior.floating, playPlaylist(
content: Text( playback,
"Copied $data to clipboard", tracksSnapshot.asData!.value,
textAlign: TextAlign.center, currentTrack: track,
), );
), }
); },
}); showShare: playlist.id != "user-liked-tracks",
}, onShare: () {
), final data = "https://open.spotify.com/playlist/${playlist.id}";
// play playlist Clipboard.setData(
IconButton( ClipboardData(text: data),
icon: Icon( ).then((_) {
isPlaylistPlaying ScaffoldMessenger.of(context).showSnackBar(
? Icons.stop_rounded SnackBar(
: Icons.play_arrow_rounded, width: 300,
), behavior: SnackBarBehavior.floating,
onPressed: tracksSnapshot.asData?.value != null content: Text(
? () => playPlaylist( "Copied $data to clipboard",
playback, textAlign: TextAlign.center,
tracksSnapshot.asData!.value,
)
: null,
)
],
), ),
), ),
Center( );
child: Text(playlist.name!, });
style: Theme.of(context).textTheme.headline4), },
), heartBtn: (auth.isLoggedIn && playlist.id != "user-liked-tracks"
tracksSnapshot.when( ? meSnapshot.when(
data: (tracks) { data: (me) {
return TracksTableView( final query = playlistIsFollowedQuery(
tracks, jsonEncode({"playlistId": playlist.id, "userId": me.id!}));
onTrackPlayButtonPressed: (currentTrack) => playPlaylist( final followingSnapshot = ref.watch(query);
playback,
tracks, return followingSnapshot.when(
currentTrack: currentTrack, data: (isFollowing) {
), return HeartButton(
playlistId: playlist.id, isLiked: isFollowing,
userPlaylist: playlist.owner?.id != null && color: color?.titleTextColor,
playlist.owner!.id == meSnapshot.asData?.value.id, 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"), error: (error, _) => Text("Error $error"),
loading: () => const CircularProgressIndicator(), loading: () => const CircularProgressIndicator(),
), )
], : null),
),
),
); );
} }
} }

View File

@ -26,7 +26,7 @@ class About extends HookWidget {
final info = usePackageInfo( final info = usePackageInfo(
appName: "Spotube", appName: "Spotube",
packageName: "oss.krtirtho.Spotube", packageName: "oss.krtirtho.Spotube",
version: "2.2.0"); version: "2.2.1");
return ListTile( return ListTile(
title: const Text("About Spotube"), title: const Text("About Spotube"),

View File

@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/About.dart';
import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart'; import 'package:spotube/components/Settings/ColorSchemePickerDialog.dart';
import 'package:spotube/components/Settings/SettingsHotkeyTile.dart'; import 'package:spotube/components/Settings/SettingsHotkeyTile.dart';
@ -133,22 +132,43 @@ class Settings extends HookConsumerWidget {
onTap: pickColorScheme(ColorSchemeType.background), onTap: pickColorScheme(ColorSchemeType.background),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
ListTile( Padding(
title: padding: const EdgeInsets.all(15),
const Text("Market Place (Recommendation Country)"), child: Wrap(
horizontalTitleGap: 10, alignment: WrapAlignment.spaceBetween,
trailing: DropdownButton( children: [
value: preferences.recommendationMarket, Column(
items: spotifyMarkets crossAxisAlignment: CrossAxisAlignment.start,
.map((country) => (DropdownMenuItem( children: [
child: Text(country), Text(
value: country, "Market Place",
))) style: Theme.of(context).textTheme.bodyText1,
.toList(), ),
onChanged: (value) { Text(
if (value == null) return; "Recommendation Country",
preferences.setRecommendationMarket(value as String); 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( ListTile(

View File

@ -4,9 +4,11 @@ class HeartButton extends StatelessWidget {
final bool isLiked; final bool isLiked;
final void Function() onPressed; final void Function() onPressed;
final IconData? icon; final IconData? icon;
final Color? color;
const HeartButton({ const HeartButton({
required this.isLiked, required this.isLiked,
required this.onPressed, required this.onPressed,
this.color,
this.icon, this.icon,
Key? key, Key? key,
}) : super(key: key); }) : super(key: key);
@ -19,7 +21,7 @@ class HeartButton extends StatelessWidget {
(!isLiked (!isLiked
? Icons.favorite_outline_rounded ? Icons.favorite_outline_rounded
: Icons.favorite_rounded), : Icons.favorite_rounded),
color: isLiked ? Theme.of(context).primaryColor : null, color: isLiked ? Theme.of(context).primaryColor : color,
), ),
onPressed: onPressed, onPressed: onPressed,
); );

View File

@ -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,
),
],
),
],
);
}
}

View File

@ -4,7 +4,11 @@ import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class TitleBarActionButtons extends StatelessWidget { class TitleBarActionButtons extends StatelessWidget {
const TitleBarActionButtons({Key? key}) : super(key: key); final Color? color;
const TitleBarActionButtons({
Key? key,
this.color,
}) : super(key: key);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -18,7 +22,10 @@ class TitleBarActionButtons extends StatelessWidget {
foregroundColor: foregroundColor:
MaterialStateProperty.all(Theme.of(context).iconTheme.color), MaterialStateProperty.all(Theme.of(context).iconTheme.color),
), ),
child: const Icon(Icons.minimize_rounded)), child: Icon(
Icons.minimize_rounded,
color: color,
)),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
appWindow.maximizeOrRestore(); appWindow.maximizeOrRestore();
@ -27,14 +34,14 @@ class TitleBarActionButtons extends StatelessWidget {
foregroundColor: foregroundColor:
MaterialStateProperty.all(Theme.of(context).iconTheme.color), MaterialStateProperty.all(Theme.of(context).iconTheme.color),
), ),
child: const Icon(Icons.crop_square_rounded)), child: Icon(Icons.crop_square_rounded, color: color)),
TextButton( TextButton(
onPressed: () { onPressed: () {
appWindow.close(); appWindow.close();
}, },
style: ButtonStyle( style: ButtonStyle(
foregroundColor: foregroundColor: MaterialStateProperty.all(
MaterialStateProperty.all(Theme.of(context).iconTheme.color), color ?? Theme.of(context).iconTheme.color),
overlayColor: MaterialStateProperty.all(Colors.redAccent), overlayColor: MaterialStateProperty.all(Colors.redAccent),
), ),
child: const Icon( child: const Icon(
@ -49,12 +56,14 @@ class PageWindowTitleBar extends StatelessWidget
implements PreferredSizeWidget { implements PreferredSizeWidget {
final Widget? leading; final Widget? leading;
final Widget? center; final Widget? center;
final bool transparent; final Color? backgroundColor;
final Color? foregroundColor;
const PageWindowTitleBar({ const PageWindowTitleBar({
Key? key, Key? key,
this.leading, this.leading,
this.center, this.center,
this.transparent = false, this.backgroundColor,
this.foregroundColor,
}) : super(key: key); }) : super(key: key);
@override @override
Size get preferredSize => Size.fromHeight( Size get preferredSize => Size.fromHeight(
@ -76,7 +85,7 @@ class PageWindowTitleBar extends StatelessWidget
} }
return WindowTitleBarBox( return WindowTitleBarBox(
child: Container( child: Container(
color: !transparent ? Theme.of(context).scaffoldBackgroundColor : null, color: backgroundColor,
child: Row( child: Row(
children: [ children: [
if (Platform.isMacOS) if (Platform.isMacOS)
@ -86,7 +95,7 @@ class PageWindowTitleBar extends StatelessWidget
if (leading != null) leading!, if (leading != null) leading!,
Expanded(child: MoveWindow(child: Center(child: center))), Expanded(child: MoveWindow(child: Center(child: center))),
if (!Platform.isMacOS && !Platform.isIOS && !Platform.isAndroid) if (!Platform.isMacOS && !Platform.isIOS && !Platform.isAndroid)
const TitleBarActionButtons() TitleBarActionButtons(color: foregroundColor)
], ],
), ),
), ),

View File

@ -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<List<TrackSimple>> 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<Widget> 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<Track>
? tracks
.map((track) => simpleTrackToTrack(track, album!))
.toList()
: tracks,
onTrackPlayButtonPressed: onPlay,
playlistId: id,
userPlaylist: isOwned,
),
],
);
},
error: (error, _) => Text("Error $error"),
loading: () => const ShimmerTrackTile(),
),
),
);
}
}

View File

@ -175,7 +175,7 @@ class TrackTile extends HookConsumerWidget {
return Container( return Container(
height: 40, height: 40,
width: 40, width: 40,
color: Colors.green[300], color: Theme.of(context).primaryColor,
); );
}, },
imageUrl: thumbnailUrl!, imageUrl: thumbnailUrl!,

View File

@ -12,12 +12,15 @@ class TracksTableView extends HookConsumerWidget {
final List<Track> tracks; final List<Track> tracks;
final bool userPlaylist; final bool userPlaylist;
final String? playlistId; final String? playlistId;
final Widget? heading;
const TracksTableView( const TracksTableView(
this.tracks, { this.tracks, {
Key? key, Key? key,
this.onTrackPlayButtonPressed, this.onTrackPlayButtonPressed,
this.userPlaylist = false, this.userPlaylist = false,
this.playlistId, this.playlistId,
this.heading,
}) : super(key: key); }) : super(key: key);
@override @override
@ -28,10 +31,79 @@ class TracksTableView extends HookConsumerWidget {
final breakpoint = useBreakpoints(); 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: Scrollbar(
child: ListView( child: ListView(
children: [ children: [
if (heading != null) heading!,
Row( Row(
children: [ children: [
Padding( Padding(

View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
class ShimmerColorTheme extends ThemeExtension<ShimmerColorTheme> {
final Color? shimmerColor;
final Color? shimmerBackgroundColor;
ShimmerColorTheme({
this.shimmerBackgroundColor,
this.shimmerColor,
});
@override
ThemeExtension<ShimmerColorTheme> copyWith(
{Color? shimmerColor, Color? shimmerBackgroundColor}) {
return ShimmerColorTheme(
shimmerBackgroundColor:
shimmerBackgroundColor ?? this.shimmerBackgroundColor,
shimmerColor: shimmerColor ?? this.shimmerColor,
);
}
@override
ThemeExtension<ShimmerColorTheme> lerp(
ThemeExtension<ShimmerColorTheme>? 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),
);
}
}

View File

@ -38,3 +38,30 @@ PaletteColor usePaletteColor(
return paletteColor; 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;
}

View File

@ -1,5 +1,60 @@
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
extension AlbumJson on AlbumSimple {
Map<String, dynamic> 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<String, dynamic> toJson() {
return {
"href": href,
"id": id,
"name": name,
"type": type,
"uri": uri,
};
}
}
extension TrackJson on Track {
Map<String, dynamic> 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 { class CurrentPlaylist {
List<Track>? _tempTrack; List<Track>? _tempTrack;
List<Track> tracks; List<Track> tracks;
@ -14,6 +69,16 @@ class CurrentPlaylist {
required this.thumbnail, required this.thumbnail,
}); });
static CurrentPlaylist fromJson(Map<String, dynamic> map) {
return CurrentPlaylist(
id: map["id"],
tracks: List.castFrom<dynamic, Track>(
map["tracks"].map((track) => Track.fromJson(track)).toList()),
name: map["name"],
thumbnail: map["thumbnail"],
);
}
List<String> get trackIds => tracks.map((e) => e.id!).toList(); List<String> get trackIds => tracks.map((e) => e.id!).toList();
bool shuffle() { bool shuffle() {
@ -35,4 +100,13 @@ class CurrentPlaylist {
} }
return false; return false;
} }
Map<String, dynamic> toJson() {
return {
"id": id,
"name": name,
"tracks": tracks.map((track) => track.toJson()).toList(),
"thumbnail": thumbnail,
};
}
} }

View File

@ -1,186 +1,188 @@
// Country Codes contributed by momobobe <https://github.com/momobobe>
final spotifyMarkets = [ final spotifyMarkets = [
"AD", ["AL", "Albania (AL)"],
"AE", ["DZ", "Algeria (DZ)"],
"AG", ["AD", "Andorra (AD)"],
"AL", ["AO", "Angola (AO)"],
"AM", ["AG", "Antigua and Barbuda (AG)"],
"AO", ["AR", "Argentina (AR)"],
"AR", ["AM", "Armenia (AM)"],
"AT", ["AU", "Australia (AU)"],
"AU", ["AT", "Austria (AT)"],
"AZ", ["AZ", "Azerbaijan (AZ)"],
"BA", ["BH", "Bahrain (BH)"],
"BB", ["BD", "Bangladesh (BD)"],
"BD", ["BB", "Barbados (BB)"],
"BE", ["BY", "Belarus (BY)"],
"BF", ["BE", "Belgium (BE)"],
"BG", ["BZ", "Belize (BZ)"],
"BH", ["BJ", "Benin (BJ)"],
"BI", ["BT", "Bhutan (BT)"],
"BJ", ["BO", "Bolivia (BO)"],
"BN", ["BA", "Bosnia and Herzegovina (BA)"],
"BO", ["BW", "Botswana (BW)"],
"BR", ["BR", "Brazil (BR)"],
"BS", ["BN", "Brunei Darussalam (BN)"],
"BT", ["BG", "Bulgaria (BG)"],
"BW", ["BF", "Burkina Faso (BF)"],
"BY", ["BI", "Burundi (BI)"],
"BZ", ["CV", "Cabo Verde / Cape Verde (CV)"],
"CA", ["KH", "Cambodia (KH)"],
"CD", ["CM", "Cameroon (CM)"],
"CG", ["CA", "Canada (CA)"],
"CH", ["TD", "Chad (TD)"],
"CI", ["CL", "Chile (CL)"],
"CL", ["CO", "Colombia (CO)"],
"CM", ["KM", "Comoros (KM)"],
"CO", ["CR", "Costa Rica (CR)"],
"CR", ["HR", "Croatia (HR)"],
"CV", ["CW", "Curaçao (CW)"],
"CW", ["CY", "Cyprus (CY)"],
"CY", ["CZ", "Czech Republic (CZ)"],
"CZ", ["CI", "Côte d'Ivoire / Ivory Coast (CI)"],
"DE", ["CD", "Democratic Republic of the Congo (CD)"],
"DJ", ["DK", "Denmark (DK)"],
"DK", ["DJ", "Djibouti (DJ)"],
"DM", ["DM", "Dominica (DM)"],
"DO", ["DO", "Dominican Republic (DO)"],
"DZ", ["EC", "Ecuador (EC)"],
"EC", ["EG", "Egypt (EG)"],
"EE", ["SV", "El Salvador (SV)"],
"EG", ["GQ", "Equatorial Guinea (GQ)"],
"ES", ["EE", "Estonia (EE)"],
"FI", ["SZ", "Eswatini (SZ)"],
"FJ", ["FJ", "Fiji (FJ)"],
"FM", ["FI", "Finland (FI)"],
"FR", ["FR", "France (FR)"],
"GA", ["GA", "Gabon (GA)"],
"GB", ["GE", "Georgia (GE)"],
"GD", ["DE", "Germany (DE)"],
"GE", ["GH", "Ghana (GH)"],
"GH", ["GR", "Greece (GR)"],
"GM", ["GD", "Grenada (GD)"],
"GN", ["GT", "Guatemala (GT)"],
"GQ", ["GN", "Guinea (GN)"],
"GR", ["GW", "Guinea-Bissau (GW)"],
"GT", ["GY", "Guyana (GY)"],
"GW", ["HT", "Haiti (HT)"],
"GY", ["HN", "Honduras (HN)"],
"HK", ["HK", "Hong Kong (HK)"],
"HN", ["HU", "Hungary (HU)"],
"HR", ["IS", "Iceland (IS)"],
"HT", ["IN", "India (IN)"],
"HU", ["ID", "Indonesia (ID)"],
"ID", ["IQ", "Iraq (IQ)"],
"IE", ["IE", "Ireland (IE)"],
"IL", ["IL", "Israel (IL)"],
"IN", ["IT", "Italy (IT)"],
"IQ", ["JM", "Jamaica (JM)"],
"IS", ["JP", "Japan (JP)"],
"IT", ["JO", "Jordan (JO)"],
"JM", ["KZ", "Kazakhstan (KZ)"],
"JO", ["KE", "Kenya (KE)"],
"JP", ["KI", "Kiribati (KI)"],
"KE", ["XK", "Kosovo (XK)"],
"KG", ["KW", "Kuwait (KW)"],
"KH", ["KG", "Kyrgyzstan (KG)"],
"KI", ["LA", "Laos (LA)"],
"KM", ["LV", "Latvia (LV)"],
"KN", ["LB", "Lebanon (LB)"],
"KR", ["LS", "Lesotho (LS)"],
"KW", ["LR", "Liberia (LR)"],
"KZ", ["LY", "Libya (LY)"],
"LA", ["LI", "Liechtenstein (LI)"],
"LB", ["LT", "Lithuania (LT)"],
"LC", ["LU", "Luxembourg (LU)"],
"LI", ["MO", "Macao / Macau (MO)"],
"LK", ["MG", "Madagascar (MG)"],
"LR", ["MW", "Malawi (MW)"],
"LS", ["MY", "Malaysia (MY)"],
"LT", ["MV", "Maldives (MV)"],
"LU", ["ML", "Mali (ML)"],
"LV", ["MT", "Malta (MT)"],
"LY", ["MH", "Marshall Islands (MH)"],
"MA", ["MR", "Mauritania (MR)"],
"MC", ["MU", "Mauritius (MU)"],
"MD", ["MX", "Mexico (MX)"],
"ME", ["FM", "Micronesia (FM)"],
"MG", ["MD", "Moldova (MD)"],
"MH", ["MC", "Monaco (MC)"],
"MK", ["MN", "Mongolia (MN)"],
"ML", ["ME", "Montenegro (ME)"],
"MN", ["MA", "Morocco (MA)"],
"MO", ["MZ", "Mozambique (MZ)"],
"MR", ["NA", "Namibia (NA)"],
"MT", ["NR", "Nauru (NR)"],
"MU", ["NP", "Nepal (NP)"],
"MV", ["NL", "Netherlands (NL)"],
"MW", ["NZ", "New Zealand (NZ)"],
"MX", ["NI", "Nicaragua (NI)"],
"MY", ["NE", "Niger (NE)"],
"MZ", ["NG", "Nigeria (NG)"],
"NA", ["MK", "North Macedonia (MK)"],
"NE", ["NO", "Norway (NO)"],
"NG", ["OM", "Oman (OM)"],
"NI", ["PK", "Pakistan (PK)"],
"NL", ["PW", "Palau (PW)"],
"NO", ["PS", "Palestine (PS)"],
"NP", ["PA", "Panama (PA)"],
"NR", ["PG", "Papua New Guinea (PG)"],
"NZ", ["PY", "Paraguay (PY)"],
"OM", ["PE", "Peru (PE)"],
"PA", ["PH", "Philippines (PH)"],
"PE", ["PL", "Poland (PL)"],
"PG", ["PT", "Portugal (PT)"],
"PH", ["QA", "Qatar (QA)"],
"PK", ["CG", "Republic of the Congo (CG)"],
"PL", ["RO", "Romania (RO)"],
"PS", ["RU", "Russia (RU)"],
"PT", ["RW", "Rwanda (RW)"],
"PW", ["WS", "Samoa (WS)"],
"PY", ["SM", "San Marino (SM)"],
"QA", ["SA", "Saudi Arabia (SA)"],
"RO", ["SN", "Senegal (SN)"],
"RS", ["RS", "Serbia (RS)"],
"RU", ["SC", "Seychelles (SC)"],
"RW", ["SL", "Sierra Leone (SL)"],
"SA", ["SG", "Singapore (SG)"],
"SB", ["SK", "Slovakia (SK)"],
"SC", ["SI", "Slovenia (SI)"],
"SE", ["SB", "Solomon Islands (SB)"],
"SG", ["ZA", "South Africa (ZA)"],
"SI", ["KR", "South Korea (KR)"],
"SK", ["ES", "Spain (ES)"],
"SL", ["LK", "Sri Lanka (LK)"],
"SM", ["VC", "St Vincent and the Grenadines (VC)"],
"SN", ["KN", "St. Kitts and Nevis (KN)"],
"SR", ["LC", "St. Lucia (LC)"],
"ST", ["SR", "Suriname (SR)"],
"SV", ["SE", "Sweden (SE)"],
"SZ", ["CH", "Switzerland (CH)"],
"TD", ["ST", "São Tomé and Príncipe (ST)"],
"TG", ["TW", "Taiwan (TW)"],
"TH", ["TJ", "Tajikistan (TJ)"],
"TJ", ["TZ", "Tanzania (TZ)"],
"TL", ["TH", "Thailand (TH)"],
"TN", ["BS", "The Bahamas (BS)"],
"TO", ["GM", "The Gambia (GM)"],
"TR", ["TL", "Timor-Leste / East Timor (TL)"],
"TT", ["TG", "Togo (TG)"],
"TV", ["TO", "Tonga (TO)"],
"TW", ["TT", "Trinidad and Tobago (TT)"],
"TZ", ["TN", "Tunisia (TN)"],
"UA", ["TR", "Turkey (TR)"],
"UG", ["TV", "Tuvalu (TV)"],
"US", ["UG", "Uganda (UG)"],
"UY", ["UA", "Ukraine (UA)"],
"UZ", ["AE", "United Arab Emirates (AE)"],
"VC", ["GB", "United Kingdom (GB)"],
"VE", ["US", "United States (US)"],
"VN", ["UY", "Uruguay (UY)"],
"VU", ["UZ", "Uzbekistan (UZ)"],
"WS", ["VU", "Vanuatu (VU)"],
"XK", ["VE", "Venezuela (VE)"],
"ZA", ["VN", "Vietnam (VN)"],
"ZM", ["ZM", "Zambia (ZM)"],
"ZW" ["Z", "Zimbabwe (ZW)"],
]; ];

View File

@ -58,7 +58,7 @@ class Auth extends PersistedChangeNotifier {
_refreshToken = null; _refreshToken = null;
_expiration = null; _expiration = null;
notifyListeners(); notifyListeners();
updatePersistence(); updatePersistence(clearNullEntries: true);
} }
@override @override

View File

@ -1,9 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:audio_service/audio_service.dart'; import 'package:audio_service/audio_service.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/helpers/artist-to-string.dart'; import 'package:spotube/helpers/artist-to-string.dart';
import 'package:spotube/helpers/image-to-url-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/UserPreferences.dart';
import 'package:spotube/provider/YouTube.dart'; import 'package:spotube/provider/YouTube.dart';
import 'package:spotube/utils/AudioPlayerHandler.dart'; import 'package:spotube/utils/AudioPlayerHandler.dart';
import 'package:spotube/utils/PersistedChangeNotifier.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class Playback extends ChangeNotifier { class Playback extends PersistedChangeNotifier {
AudioSource? _currentAudioSource; AudioSource? _currentAudioSource;
final _logger = getLogger(Playback); final _logger = getLogger(Playback);
CurrentPlaylist? _currentPlaylist; CurrentPlaylist? _currentPlaylist;
@ -38,13 +40,15 @@ class Playback extends ChangeNotifier {
CurrentPlaylist? currentPlaylist, CurrentPlaylist? currentPlaylist,
Track? currentTrack, Track? currentTrack,
}) : _currentPlaylist = currentPlaylist, }) : _currentPlaylist = currentPlaylist,
_currentTrack = currentTrack { _currentTrack = currentTrack,
super() {
player.onNextRequest = () { player.onNextRequest = () {
movePlaylistPositionBy(1); movePlaylistPositionBy(1);
}; };
player.onPreviousRequest = () { player.onPreviousRequest = () {
movePlaylistPositionBy(-1); movePlaylistPositionBy(-1);
}; };
_init(); _init();
} }
@ -52,7 +56,7 @@ class Playback extends ChangeNotifier {
StreamSubscription<Duration>? _positionStream; StreamSubscription<Duration>? _positionStream;
StreamSubscription<bool>? _playingStream; StreamSubscription<bool>? _playingStream;
void _init() { void _init() async {
_playingStream = player.core.playingStream.listen( _playingStream = player.core.playingStream.listen(
(playing) { (playing) {
_isPlaying = playing; _isPlaying = playing;
@ -119,12 +123,14 @@ class Playback extends ChangeNotifier {
_logger.v("[Setting Current Track] ${track.name} - ${track.id}"); _logger.v("[Setting Current Track] ${track.name} - ${track.id}");
_currentTrack = track; _currentTrack = track;
notifyListeners(); notifyListeners();
updatePersistence();
} }
set setCurrentPlaylist(CurrentPlaylist playlist) { set setCurrentPlaylist(CurrentPlaylist playlist) {
_logger.v("[Current Playlist Changed] ${playlist.name} - ${playlist.id}"); _logger.v("[Current Playlist Changed] ${playlist.name} - ${playlist.id}");
_currentPlaylist = playlist; _currentPlaylist = playlist;
notifyListeners(); notifyListeners();
updatePersistence();
} }
void reset() { void reset() {
@ -135,6 +141,7 @@ class Playback extends ChangeNotifier {
_currentPlaylist = null; _currentPlaylist = null;
_currentTrack = null; _currentTrack = null;
notifyListeners(); notifyListeners();
updatePersistence(clearNullEntries: true);
} }
/// sets the provided id matched track's uri\ /// sets the provided id matched track's uri\
@ -147,6 +154,7 @@ class Playback extends ChangeNotifier {
_currentPlaylist!.tracks.indexWhere((element) => element.id == id); _currentPlaylist!.tracks.indexWhere((element) => element.id == id);
if (index == -1) return false; if (index == -1) return false;
_currentPlaylist!.tracks[index].uri = uri; _currentPlaylist!.tracks[index].uri = uri;
updatePersistence();
return _currentPlaylist!.tracks[index].uri == uri; return _currentPlaylist!.tracks[index].uri == uri;
} catch (e) { } catch (e) {
return false; return false;
@ -170,6 +178,7 @@ class Playback extends ChangeNotifier {
duration = null; duration = null;
_currentTrack = track; _currentTrack = track;
notifyListeners(); notifyListeners();
updatePersistence();
// starts to play the newly entered next/prev track // starts to play the newly entered next/prev track
startPlaying(); startPlaying();
} }
@ -202,6 +211,7 @@ class Playback extends ChangeNotifier {
.then((value) async { .then((value) async {
_currentTrack = track; _currentTrack = track;
notifyListeners(); notifyListeners();
updatePersistence();
}); });
// await player.play(); // await player.play();
return; return;
@ -215,6 +225,7 @@ class Playback extends ChangeNotifier {
audioQuality: preferences.audioQuality, audioQuality: preferences.audioQuality,
); );
if (setTrackUriById(track.id!, spotubeTrack.ytUri)) { if (setTrackUriById(track.id!, spotubeTrack.ytUri)) {
logger.v("[Track Direct Source] - ${spotubeTrack.ytUri}");
_currentAudioSource = AudioSource.uri(Uri.parse(spotubeTrack.ytUri)); _currentAudioSource = AudioSource.uri(Uri.parse(spotubeTrack.ytUri));
await player.core await player.core
.setAudioSource( .setAudioSource(
@ -224,6 +235,7 @@ class Playback extends ChangeNotifier {
.then((value) { .then((value) {
_currentTrack = spotubeTrack; _currentTrack = spotubeTrack;
notifyListeners(); notifyListeners();
updatePersistence();
}); });
// await player.play(); // await player.play();
} }
@ -246,6 +258,36 @@ class Playback extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
} }
@override
FutureOr<void> loadFromLocal(Map<String, dynamic> 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<Map<String, dynamic>> toMap() {
return {
"currentPlaylist": currentPlaylist != null
? jsonEncode(currentPlaylist?.toJson())
: null,
"currentTrack":
currentTrack != null ? jsonEncode(currentTrack?.toJson()) : null,
};
}
} }
final playbackProvider = ChangeNotifierProvider<Playback>((ref) { final playbackProvider = ChangeNotifierProvider<Playback>((ref) {

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/helpers/getLyrics.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/helpers/timed-lyrics.dart';
import 'package:spotube/models/SpotubeTrack.dart'; import 'package:spotube/models/SpotubeTrack.dart';
import 'package:spotube/provider/Playback.dart'; import 'package:spotube/provider/Playback.dart';
@ -118,9 +119,18 @@ final albumTracksQuery = FutureProvider.family<List<TrackSimple>, String>(
); );
final currentUserQuery = FutureProvider<User>( final currentUserQuery = FutureProvider<User>(
(ref) { (ref) async {
final spotify = ref.watch(spotifyProvider); 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;
}, },
); );

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotube/extensions/ShimmerColorTheme.dart';
ThemeData darkTheme({ ThemeData darkTheme({
required MaterialColor accentMaterialColor, required MaterialColor accentMaterialColor,
@ -7,6 +8,12 @@ ThemeData darkTheme({
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
extensions: [
ShimmerColorTheme(
shimmerBackgroundColor: backgroundMaterialColor[700],
shimmerColor: backgroundMaterialColor[800],
)
],
primaryColor: accentMaterialColor, primaryColor: accentMaterialColor,
primarySwatch: accentMaterialColor, primarySwatch: accentMaterialColor,
backgroundColor: backgroundMaterialColor[900], backgroundColor: backgroundMaterialColor[900],

View File

@ -1,14 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotube/extensions/ShimmerColorTheme.dart';
final materialWhite = MaterialColor(Colors.white.value, { final materialWhite = MaterialColor(Colors.white.value, {
50: Colors.white, 50: Colors.white,
100: Colors.blueGrey[50]!, 100: Colors.blueGrey[50]!,
200: Colors.white, 200: Colors.white,
300: Colors.white, 300: Colors.white,
400: Colors.white, 400: Colors.blueGrey[300]!,
500: Colors.blueGrey, 500: Colors.blueGrey,
600: Colors.white, 600: Colors.white,
700: Colors.white, 700: Colors.grey[700]!,
800: Colors.white, 800: Colors.white,
900: Colors.white, 900: Colors.white,
}); });
@ -19,6 +20,12 @@ ThemeData lightTheme({
}) { }) {
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
extensions: [
ShimmerColorTheme(
shimmerBackgroundColor: backgroundMaterialColor[200],
shimmerColor: backgroundMaterialColor[300],
)
],
primaryColor: accentMaterialColor, primaryColor: accentMaterialColor,
primarySwatch: accentMaterialColor, primarySwatch: accentMaterialColor,
buttonTheme: ButtonThemeData( buttonTheme: ButtonThemeData(

View File

@ -37,7 +37,7 @@ abstract class PersistedChangeNotifier extends ChangeNotifier {
FutureOr<Map<String, dynamic>> toMap(); FutureOr<Map<String, dynamic>> toMap();
Future<void> updatePersistence() async { Future<void> updatePersistence({bool clearNullEntries = false}) async {
for (final entry in (await toMap()).entries) { for (final entry in (await toMap()).entries) {
if (entry.value is bool) { if (entry.value is bool) {
await _localStorage.setBool(entry.key, entry.value); await _localStorage.setBool(entry.key, entry.value);
@ -47,6 +47,8 @@ abstract class PersistedChangeNotifier extends ChangeNotifier {
await _localStorage.setDouble(entry.key, entry.value); await _localStorage.setDouble(entry.key, entry.value);
} else if (entry.value is String) { } else if (entry.value is String) {
await _localStorage.setString(entry.key, entry.value); await _localStorage.setString(entry.key, entry.value);
} else if (entry.value == null && clearNullEntries) {
_localStorage.remove(entry.key);
} }
} }
} }

View File

@ -702,6 +702,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter

View File

@ -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. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 2.2.0+10 version: 2.2.1+11
environment: environment:
sdk: ">=2.15.1 <3.0.0" sdk: ">=2.15.1 <3.0.0"
@ -61,6 +61,7 @@ dependencies:
version: ^2.0.0 version: ^2.0.0
audio_service: ^0.18.4 audio_service: ^0.18.4
hookified_infinite_scroll_pagination: ^0.1.0 hookified_infinite_scroll_pagination: ^0.1.0
skeleton_text: ^3.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: