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
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
### New

View File

@ -44,7 +44,13 @@ Following are the features that currently spotube offers:
- Synced Lyrics
- 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
@ -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'/>
- **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/>
<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:
- [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

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/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<Track> 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,
);
}
}

View File

@ -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';

View File

@ -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<Widget>(
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<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() ??
[],
),
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<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"),
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(),
),
),
);
}

View File

@ -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<PlaylistSimple>(
noItemsFoundIndicatorBuilder: (context) {
return const NotFound();
},
firstPageProgressIndicatorBuilder: (context) {
return const ShimmerPlaybuttonCard();
},
newPageProgressIndicatorBuilder: (context) {
return const ShimmerPlaybuttonCard();
},
itemBuilder: (context, playlist, index) {
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/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<Category>(
firstPageProgressIndicatorBuilder: (_) =>
const ShimmerCategories(),
newPageProgressIndicatorBuilder: (_) =>
const ShimmerCategories(),
itemBuilder: (context, item, index) {
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: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<User>(
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(),
),
);
}

View File

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

View File

@ -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;

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,
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,
),

View File

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

View File

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

View File

@ -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(

View File

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

View File

@ -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"),

View File

@ -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(

View File

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

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

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(
height: 40,
width: 40,
color: Colors.green[300],
color: Theme.of(context).primaryColor,
);
},
imageUrl: thumbnailUrl!,

View File

@ -12,12 +12,15 @@ class TracksTableView extends HookConsumerWidget {
final List<Track> 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(

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;
}
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';
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 {
List<Track>? _tempTrack;
List<Track> tracks;
@ -14,6 +69,16 @@ class CurrentPlaylist {
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();
bool shuffle() {
@ -35,4 +100,13 @@ class CurrentPlaylist {
}
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 = [
"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)"],
];

View File

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

View File

@ -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<Duration>? _positionStream;
StreamSubscription<bool>? _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<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) {

View File

@ -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<List<TrackSimple>, String>(
);
final currentUserQuery = FutureProvider<User>(
(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;
},
);

View File

@ -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],

View File

@ -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(

View File

@ -37,7 +37,7 @@ abstract class PersistedChangeNotifier extends ChangeNotifier {
FutureOr<Map<String, dynamic>> toMap();
Future<void> updatePersistence() async {
Future<void> 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);
}
}
}

View File

@ -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

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.
# 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: