mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
Merge branch 'master' into build
This commit is contained in:
commit
56d5f68e8c
5
.github/FUNDING.yml
vendored
5
.github/FUNDING.yml
vendored
@ -1,2 +1,5 @@
|
||||
open_collective: spotube
|
||||
ko_fi: krtirtho
|
||||
ko_fi: krtirtho
|
||||
patreon: krtirtho
|
||||
custom:
|
||||
- "https://www.buymeacoffee.com/krtirtho"
|
||||
|
11
CHANGELOG.md
11
CHANGELOG.md
@ -1,3 +1,14 @@
|
||||
# v2.2.1
|
||||
|
||||
### Improved
|
||||
- Page transitions defaulted to material you design
|
||||
|
||||
### Bug fixes
|
||||
- Mini Player flickering on random state updates
|
||||
- Track More Options not showing when not logged in
|
||||
- Wrong link to Client ID & Client Secret tutorial in Login page
|
||||
- Changing preferences in Settings resets the entire Playback
|
||||
|
||||
# v2.2.0
|
||||
|
||||
### New
|
||||
|
27
README.md
27
README.md
@ -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>
|
||||
|
||||
[](https://opencollective.com/spotube)
|
||||
[](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
|
||||
@ -169,4 +166,4 @@ Follow me on [Twitter](https://twitter.com/@krtirtho) for newer updates about th
|
||||
[mac-dlink]: https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-macos-x86_64.dmg
|
||||
[android-dlink]: https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-android-all-arch.apk
|
||||
|
||||
[wiki-installation-instructions]: https://github.com/KRTirtho/spotube/wiki/Installation-Instrcutions
|
||||
[wiki-installation-instructions]: https://github.com/KRTirtho/spotube/wiki/Installation-Instrcutions
|
||||
|
BIN
assets/empty_box.png
Normal file
BIN
assets/empty_box.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 167 KiB |
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
},
|
||||
|
@ -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);
|
||||
},
|
||||
|
@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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"),
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
51
lib/components/LoaderShimmers/ShimmerArtistProfile.dart
Normal file
51
lib/components/LoaderShimmers/ShimmerArtistProfile.dart
Normal 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()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
44
lib/components/LoaderShimmers/ShimmerCategories.dart
Normal file
44
lib/components/LoaderShimmers/ShimmerCategories.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
87
lib/components/LoaderShimmers/ShimmerPlaybuttonCard.dart
Normal file
87
lib/components/LoaderShimmers/ShimmerPlaybuttonCard.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
85
lib/components/LoaderShimmers/ShimmerTrackTile.dart
Normal file
85
lib/components/LoaderShimmers/ShimmerTrackTile.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -30,7 +30,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
return Container(
|
||||
height: 50,
|
||||
width: 50,
|
||||
color: Colors.green[400],
|
||||
color: Theme.of(context).primaryColor,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@ -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(
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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"),
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
);
|
||||
|
30
lib/components/Shared/NotFound.dart
Normal file
30
lib/components/Shared/NotFound.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
240
lib/components/Shared/TrackCollectionView.dart
Normal file
240
lib/components/Shared/TrackCollectionView.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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!,
|
||||
|
@ -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(
|
||||
|
34
lib/extensions/ShimmerColorTheme.dart
Normal file
34
lib/extensions/ShimmerColorTheme.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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)"],
|
||||
];
|
||||
|
@ -58,7 +58,7 @@ class Auth extends PersistedChangeNotifier {
|
||||
_refreshToken = null;
|
||||
_expiration = null;
|
||||
notifyListeners();
|
||||
updatePersistence();
|
||||
updatePersistence(clearNullEntries: true);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -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],
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user