diff --git a/README.md b/README.md
index 34ca53ae..128b1d34 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
@@ -46,7 +46,7 @@ All the binaries are located in the [releases](https://github.com/krtirtho/spotu
## Windows
-Download the [setup file](https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-windows-x86_64-setup.exe) & follow along the installer
+Download the [setup file](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-windows-x86_64-setup.exe) & follow along the installer
### Chocolatey
@@ -71,7 +71,7 @@ $ flatpak install flathub com.github.KRTirtho.Spotube
### Ubuntu/Debian/Linux Mint/Pop_!OS:
- Download the [Spotube-linux-x86_64.deb](https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.deb) then double click it or run
+ Download the [Spotube-linux-x86_64.deb](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-linux-x86_64.deb) then double click it or run
```bash
$ sudo apt install Spotube-linux-x86_64.deb
# or
@@ -91,10 +91,13 @@ $ flatpak install flathub com.github.KRTirtho.Spotube
### AppImage:
- Download the [Spotube-linux-x86_64.AppImage](https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.AppImage) file & double click to run it. AppImages require [appimage-launcher](https://github.com/TheAssassin/AppImageLauncher) to be installed
+ Download the [Spotube-linux-x86_64.AppImage](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-linux-x86_64.AppImage) file & double click to run it. AppImages require [appimage-launcher](https://github.com/TheAssassin/AppImageLauncher) to be installed
-**I'll/try to upload the package binaries to linux debian/arch/ubuntu/snap/flatpack/redhat/chocolatey stores or software centers or repositories**
+## Mac OS
+Download the [Mac OS Disk Image (.dmg) file](https://github.com/KRTirtho/spotube/releases/latest/download/Spotube-macos-x86_64.dmg) from the release & follow along the setup wizard
+
+**I'll/try to upload the package binaries to linux debian/arch/ubuntu/snap/flatpack/redhat/chocolatey/homebrew stores or software centers or repositories**
# Configuration
There are some configurations that needs to be done to start using this software
@@ -130,7 +133,7 @@ Also, you need a [genius](https://genius.com) account for **lyrics** & a API Cli
- [x] Add support for show Lyric of currently playing track
- [x] Track download
- [ ] Support for playing/streaming podcasts/shows
-- [ ] Artist, User & Album pages
+- [x] Artist, User & Album pages
# Building from source
@@ -149,7 +152,6 @@ $ flutter run -d
- Shows & Podcasts aren't supported as it'd require premium anyway
- OS Media Controls
-- Global Media Shortcuts/Keyboard Media Buttons
# License
@@ -178,4 +180,4 @@ Bu why? You can learn about it [here](https://dev.to/krtirtho/choosing-open-sour
Follow me on [Twitter](https://twitter.com/@krtirtho) for newer updates about this application
-© 2022 Spotube
\ No newline at end of file
+© 2022 Spotube
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 19246de8..591455ab 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -1,34 +1,30 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
+
+
+
\ No newline at end of file
diff --git a/android/build.gradle b/android/build.gradle
index 24047dce..4256f917 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -1,5 +1,5 @@
buildscript {
- ext.kotlin_version = '1.3.50'
+ ext.kotlin_version = '1.6.10'
repositories {
google()
mavenCentral()
diff --git a/assets/placeholder.png b/assets/placeholder.png
new file mode 100644
index 00000000..6f6d451f
Binary files /dev/null and b/assets/placeholder.png differ
diff --git a/assets/warmer.mp3 b/assets/warmer.mp3
new file mode 100644
index 00000000..638976b2
Binary files /dev/null and b/assets/warmer.mp3 differ
diff --git a/aur-struct/.SRCINFO b/aur-struct/.SRCINFO
index 7f5956bc..36027837 100644
--- a/aur-struct/.SRCINFO
+++ b/aur-struct/.SRCINFO
@@ -1,12 +1,12 @@
pkgbase = spotube-bin
pkgdesc = A lightweight free Spotify desktop-client which handles playback manually, streams music using Youtube & no Spotify premium account is needed
pkgver = 1.2.0
- pkgrel = 1
+ pkgrel = 2
url = https://github.com/KRTirtho/spotube/
arch = x86_64
license = BSD-4-Clause
depends = libkeybinder3
source = https://github.com/KRTirtho/spotube/releases/download/v1.2.0/Spotube-linux-x86_64.tar.xz
- md5sums = 0db87627ddf753bc7f09ebbb282184ee
+ md5sums = f49d21ef00c7d43eb70e7e9b2a7103c1
pkgname = spotube-bin
diff --git a/aur-struct/PKGBUILD b/aur-struct/PKGBUILD
index 4e7ceb3e..3e7ee60b 100644
--- a/aur-struct/PKGBUILD
+++ b/aur-struct/PKGBUILD
@@ -1,7 +1,7 @@
# Maintainer: Kingkor Roy Tirtho
pkgname=spotube-bin
pkgver=1.2.0
-pkgrel=1
+pkgrel=2
epoch=
pkgdesc="A lightweight free Spotify desktop-client which handles playback manually, streams music using Youtube & no Spotify premium account is needed"
arch=(x86_64)
@@ -21,16 +21,20 @@ install=
changelog=
source=("https://github.com/KRTirtho/spotube/releases/download/v${pkgver}/Spotube-linux-x86_64.tar.xz")
noextract=()
-md5sums=(0db87627ddf753bc7f09ebbb282184ee)
+md5sums=(f49d21ef00c7d43eb70e7e9b2a7103c1)
validpgpkeys=()
package(){
- install -dm755 "${pkgdir}/usr/share/icons/${pkgname}"
+ install -dm755 "${pkgdir}/usr/share/icons/spotube"
install -dm755 "${pkgdir}/usr/share/applications"
+ install -dm755 "${pkgdir}/usr/share/appdata"
install -dm755 "${pkgdir}/usr/share/${pkgname}"
install -dm755 "${pkgdir}/usr/bin"
- cp -ra ./ "${pkgdir}/usr/share/${pkgname}"
- cp ./spotube.desktop "${pkgdir}/usr/share/applications"
- cp ./spotube-logo.png "${pkgdir}/usr/share/icons/${pkgname}"
- ln -s "/usr/share/${pkgname}/spotube" "${pkgdir}/usr/bin/${pkgname}"
+
+ mv ./spotube.desktop "${pkgdir}/usr/share/applications"
+ mv ./spotube-logo.png "${pkgdir}/usr/share/icons/spotube/"
+ mv ./com.github.KRTirtho.Spotube.appdata.xml "${pkgdir}/usr/share/appdata/spotube.appdata.xml"
+ cp -ra ./data ./lib ./spotube "${pkgdir}/usr/share/${pkgname}"
+ sed -i 's|com.github.KRTirtho.Spotube|spotube|' "${pkgdir}/usr/share/appdata/spotube.appdata.xml"
+ ln -s "/usr/share/${pkgname}/spotube" "${pkgdir}/usr/bin/spotube"
}
diff --git a/choco-struct/tools/VERIFICATION.txt b/choco-struct/tools/VERIFICATION.txt
index b24d0da2..4c71818e 100644
--- a/choco-struct/tools/VERIFICATION.txt
+++ b/choco-struct/tools/VERIFICATION.txt
@@ -7,7 +7,7 @@ in verifying that this package's contents are trustworthy.
Please go to releases page
https://github.com/KRTirtho/spotube/releases
-Download same version as this choco package (example for v1.1.0)
+Download same version as this choco package (example for v1.2.0)
https://github.com/KRTirtho/spotube/releases/tag/v1.0.1
1. get hashes. Run:
@@ -15,9 +15,9 @@ powershell -command Get-FileHash tools\Spotube-windows-x86_64-setup.exe
2. The checksums should match the following:
---
-Version Hashes for v1.1.0
+Version Hashes for v1.2.0
Algorithm Hash Path
--------- ---- ----
-SHA256 144fb4170b424ae9ecee8941354244cb9744c0913fdc69f730a8b5e40e56753d tools\Spotube-windows-x86_64-setup.exe
\ No newline at end of file
+SHA256 02c032e1a2b8f60969b7a65c6a5e21df2bf5834cc8d8062cf56a2c8245a2a90e tools\Spotube-windows-x86_64-setup.exe
\ No newline at end of file
diff --git a/ios/Podfile b/ios/Podfile
index 1e8c3c90..42542813 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -37,5 +37,11 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
+ target.build_configurations.each do |config|
+ config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
+ '$(inherited)',
+ 'AUDIO_SESSION_MICROPHONE=0'
+ ]
+ end
end
end
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 474ff9f7..05d639bc 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -1,47 +1,54 @@
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleDisplayName
- Sptube
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- spotube
- CFBundlePackageType
- APPL
- CFBundleShortVersionString
- $(FLUTTER_BUILD_NAME)
- CFBundleSignature
- ????
- CFBundleVersion
- $(FLUTTER_BUILD_NUMBER)
- LSRequiresIPhoneOS
-
- UILaunchStoryboardName
- LaunchScreen
- UIMainStoryboardFile
- Main
- UISupportedInterfaceOrientations
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UISupportedInterfaceOrientations~ipad
-
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
-
- UIViewControllerBasedStatusBarAppearance
-
-
-
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Sptube
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ spotube
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIViewControllerBasedStatusBarAppearance
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsArbitraryLoadsForMedia
+
+
+
+
\ No newline at end of file
diff --git a/lib/components/Album/AlbumCard.dart b/lib/components/Album/AlbumCard.dart
index eed381de..81163b1b 100644
--- a/lib/components/Album/AlbumCard.dart
+++ b/lib/components/Album/AlbumCard.dart
@@ -1,40 +1,42 @@
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumView.dart';
import 'package:spotube/components/Shared/PlaybuttonCard.dart';
+import 'package:spotube/components/Shared/SpotubePageRoute.dart';
import 'package:spotube/helpers/artist-to-string.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/simple-track-to-track.dart';
+import 'package:spotube/hooks/useBreakpointValue.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class AlbumCard extends StatelessWidget {
+class AlbumCard extends HookConsumerWidget {
final Album album;
const AlbumCard(this.album, {Key? key}) : super(key: key);
@override
- Widget build(BuildContext context) {
- Playback playback = context.watch();
+ Widget build(BuildContext context, ref) {
+ Playback playback = ref.watch(playbackProvider);
bool isPlaylistPlaying = playback.currentPlaylist != null &&
playback.currentPlaylist!.id == album.id;
-
+ final int marginH =
+ useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
return PlaybuttonCard(
imageUrl: imageToUrlString(album.images),
+ margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
isPlaying: playback.currentPlaylist?.id != null &&
playback.currentPlaylist?.id == album.id,
title: album.name!,
description:
"Album • ${artistsToString(album.artists ?? [])}",
onTap: () {
- Navigator.of(context).push(MaterialPageRoute(
- builder: (context) {
- return AlbumView(album);
- },
- ));
+ GoRouter.of(context).push("/album/${album.id}", extra: album);
},
onPlaybuttonPressed: () async {
- SpotifyApi spotify = context.read().spotifyApi;
+ SpotifyApi spotify = ref.read(spotifyProvider);
if (isPlaylistPlaying) return;
List tracks = (await spotify.albums.getTracks(album.id!).all())
.map((track) => simpleTrackToTrack(track, album))
@@ -48,6 +50,7 @@ class AlbumCard extends StatelessWidget {
thumbnail: album.images!.first.url!,
);
playback.setCurrentTrack = tracks.first;
+ await playback.startPlaying();
},
);
}
diff --git a/lib/components/Album/AlbumView.dart b/lib/components/Album/AlbumView.dart
index 88f74b7e..2054602d 100644
--- a/lib/components/Album/AlbumView.dart
+++ b/lib/components/Album/AlbumView.dart
@@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TracksTableView.dart';
@@ -8,11 +8,12 @@ import 'package:spotube/helpers/simple-track-to-track.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class AlbumView extends StatelessWidget {
+class AlbumView extends ConsumerWidget {
final AlbumSimple album;
const AlbumView(this.album, {Key? key}) : super(key: key);
- playPlaylist(Playback playback, List tracks, {Track? currentTrack}) {
+ playPlaylist(Playback playback, List tracks,
+ {Track? currentTrack}) async {
currentTrack ??= tracks.first;
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
if (!isPlaylistPlaying) {
@@ -28,71 +29,74 @@ class AlbumView extends StatelessWidget {
currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack;
}
+ await playback.startPlaying();
}
@override
- Widget build(BuildContext context) {
- Playback playback = context.watch();
+ Widget build(BuildContext context, ref) {
+ Playback playback = ref.watch(playbackProvider);
var isPlaylistPlaying = playback.currentPlaylist?.id == album.id;
- SpotifyApi spotify = context.watch().spotifyApi;
- return Scaffold(
- body: FutureBuilder>(
- future: spotify.albums.getTracks(album.id!).all(),
- builder: (context, snapshot) {
- List tracks = snapshot.data?.map((trackSmp) {
- return simpleTrackToTrack(trackSmp, album);
- }).toList() ??
- [];
- return Column(
- children: [
- PageWindowTitleBar(
- leading: Row(
- children: [
- // nav back
- const BackButton(),
- // heart playlist
- IconButton(
- icon: const Icon(Icons.favorite_outline_rounded),
- onPressed: () {},
- ),
- // play playlist
- IconButton(
- icon: Icon(
- isPlaylistPlaying
- ? Icons.stop_rounded
- : Icons.play_arrow_rounded,
+ SpotifyApi spotify = ref.watch(spotifyProvider);
+ return SafeArea(
+ child: Scaffold(
+ body: FutureBuilder>(
+ future: spotify.albums.getTracks(album.id!).all(),
+ builder: (context, snapshot) {
+ List tracks = snapshot.data?.map((trackSmp) {
+ return simpleTrackToTrack(trackSmp, album);
+ }).toList() ??
+ [];
+ return Column(
+ children: [
+ PageWindowTitleBar(
+ leading: Row(
+ children: [
+ // nav back
+ const BackButton(),
+ // heart playlist
+ IconButton(
+ icon: const Icon(Icons.favorite_outline_rounded),
+ onPressed: () {},
),
- onPressed: snapshot.hasData
- ? () => playPlaylist(playback, tracks)
- : null,
- )
- ],
- ),
- ),
- Center(
- child: Text(album.name!,
- style: Theme.of(context).textTheme.headline4),
- ),
- snapshot.hasError
- ? const Center(child: Text("Error occurred"))
- : !snapshot.hasData
- ? const Expanded(
- child: Center(
- child: CircularProgressIndicator.adaptive()),
- )
- : TracksTableView(
- tracks,
- onTrackPlayButtonPressed: (currentTrack) =>
- playPlaylist(
- playback,
- tracks,
- currentTrack: currentTrack,
- ),
+ // play playlist
+ IconButton(
+ icon: Icon(
+ isPlaylistPlaying
+ ? Icons.stop_rounded
+ : Icons.play_arrow_rounded,
),
- ],
- );
- }),
+ onPressed: snapshot.hasData
+ ? () => playPlaylist(playback, tracks)
+ : null,
+ )
+ ],
+ ),
+ ),
+ Center(
+ child: Text(album.name!,
+ style: Theme.of(context).textTheme.headline4),
+ ),
+ snapshot.hasError
+ ? const Center(child: Text("Error occurred"))
+ : !snapshot.hasData
+ ? const Expanded(
+ child: Center(
+ child: CircularProgressIndicator.adaptive()),
+ )
+ : TracksTableView(
+ tracks,
+ onTrackPlayButtonPressed: (currentTrack) =>
+ playPlaylist(
+ playback,
+ tracks,
+ currentTrack: currentTrack,
+ ),
+ ),
+ ],
+ );
+ }),
+ ),
);
}
}
diff --git a/lib/components/Artist/ArtistAlbumView.dart b/lib/components/Artist/ArtistAlbumView.dart
index 9f0906fd..b90a78a6 100644
--- a/lib/components/Artist/ArtistAlbumView.dart
+++ b/lib/components/Artist/ArtistAlbumView.dart
@@ -1,12 +1,12 @@
import 'package:flutter/material.dart' hide Page;
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
-import 'package:provider/provider.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumCard.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class ArtistAlbumView extends StatefulWidget {
+class ArtistAlbumView extends ConsumerStatefulWidget {
final String artistId;
final String artistName;
const ArtistAlbumView(
@@ -16,10 +16,10 @@ class ArtistAlbumView extends StatefulWidget {
}) : super(key: key);
@override
- State createState() => _ArtistAlbumViewState();
+ ConsumerState createState() => _ArtistAlbumViewState();
}
-class _ArtistAlbumViewState extends State {
+class _ArtistAlbumViewState extends ConsumerState {
final PagingController _pagingController =
PagingController(firstPageKey: 0);
@@ -39,10 +39,9 @@ class _ArtistAlbumViewState extends State {
_fetchPage(int pageKey) async {
try {
- SpotifyDI data = context.read();
- Page albums = await data.spotifyApi.artists
- .albums(widget.artistId)
- .getPage(8, pageKey);
+ SpotifyApi spotifyApi = ref.watch(spotifyProvider);
+ Page albums =
+ await spotifyApi.artists.albums(widget.artistId).getPage(8, pageKey);
var items = albums.items!.toList();
@@ -60,32 +59,34 @@ class _ArtistAlbumViewState extends State {
@override
Widget build(BuildContext context) {
- return Scaffold(
- appBar: const PageWindowTitleBar(leading: BackButton()),
- body: Column(
- children: [
- Text(
- widget.artistName,
- style: Theme.of(context).textTheme.headline4,
- ),
- Expanded(
- child: PagedGridView(
- pagingController: _pagingController,
- gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
- maxCrossAxisExtent: 260,
- childAspectRatio: 9 / 13,
- crossAxisSpacing: 20,
- mainAxisSpacing: 20,
- ),
- padding: const EdgeInsets.all(10),
- builderDelegate: PagedChildBuilderDelegate(
- itemBuilder: (context, item, index) {
- return AlbumCard(item);
- },
+ return SafeArea(
+ child: Scaffold(
+ appBar: const PageWindowTitleBar(leading: BackButton()),
+ body: Column(
+ children: [
+ Text(
+ widget.artistName,
+ style: Theme.of(context).textTheme.headline4,
+ ),
+ Expanded(
+ child: PagedGridView(
+ pagingController: _pagingController,
+ gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
+ maxCrossAxisExtent: 260,
+ childAspectRatio: 9 / 13,
+ crossAxisSpacing: 20,
+ mainAxisSpacing: 20,
+ ),
+ padding: const EdgeInsets.all(10),
+ builderDelegate: PagedChildBuilderDelegate(
+ itemBuilder: (context, item, index) {
+ return AlbumCard(item);
+ },
+ ),
),
),
- ),
- ],
+ ],
+ ),
),
);
}
diff --git a/lib/components/Artist/ArtistCard.dart b/lib/components/Artist/ArtistCard.dart
index 0f5099e8..9f2ead1a 100644
--- a/lib/components/Artist/ArtistCard.dart
+++ b/lib/components/Artist/ArtistCard.dart
@@ -1,7 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
import 'package:spotify/spotify.dart';
-import 'package:spotube/components/Artist/ArtistProfile.dart';
class ArtistCard extends StatelessWidget {
final Artist artist;
@@ -9,13 +9,14 @@ class ArtistCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
+ final backgroundImage = CachedNetworkImageProvider((artist
+ .images?.isNotEmpty ??
+ false)
+ ? artist.images!.first.url!
+ : "https://avatars.dicebear.com/api/open-peeps/${artist.id}.png?b=%231ed760&r=50&flip=1&translateX=3&translateY=-6");
return InkWell(
onTap: () {
- Navigator.of(context).push(MaterialPageRoute(
- builder: (context) {
- return ArtistProfile(artist.id!);
- },
- ));
+ GoRouter.of(context).push("/artist/${artist.id}");
},
borderRadius: BorderRadius.circular(10),
child: Ink(
@@ -38,11 +39,7 @@ class ArtistCard extends StatelessWidget {
CircleAvatar(
maxRadius: 80,
minRadius: 20,
- backgroundImage: CachedNetworkImageProvider((artist
- .images?.isNotEmpty ??
- false)
- ? artist.images!.first.url!
- : "https://avatars.dicebear.com/api/open-peeps/${artist.id}.png?b=%231ed760&r=50&flip=1&translateX=3&translateY=-6"),
+ backgroundImage: backgroundImage,
),
Text(
artist.name!,
diff --git a/lib/components/Artist/ArtistProfile.dart b/lib/components/Artist/ArtistProfile.dart
index 5eb839c2..f5f47f65 100644
--- a/lib/components/Artist/ArtistProfile.dart
+++ b/lib/components/Artist/ArtistProfile.dart
@@ -1,60 +1,83 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
-import 'package:provider/provider.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumCard.dart';
-import 'package:spotube/components/Artist/ArtistAlbumView.dart';
import 'package:spotube/components/Artist/ArtistCard.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TracksTableView.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
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/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class ArtistProfile extends StatefulWidget {
+class ArtistProfile extends HookConsumerWidget {
final String artistId;
const ArtistProfile(this.artistId, {Key? key}) : super(key: key);
@override
- _ArtistProfileState createState() => _ArtistProfileState();
-}
+ Widget build(BuildContext context, ref) {
+ SpotifyApi spotify = ref.watch(spotifyProvider);
+ final scrollController = useScrollController();
+ final parentScrollController = useScrollController();
+ final textTheme = Theme.of(context).textTheme;
+ final chipTextVariant = useBreakpointValue(
+ sm: textTheme.bodySmall,
+ md: textTheme.bodyMedium,
+ lg: textTheme.headline6,
+ xl: textTheme.headline6,
+ xxl: textTheme.headline6,
+ );
-class _ArtistProfileState extends State {
- @override
- Widget build(BuildContext context) {
- SpotifyApi spotify = context.watch().spotifyApi;
- return Scaffold(
- appBar: const PageWindowTitleBar(
- leading: BackButton(),
- ),
- body: FutureBuilder(
- future: spotify.artists.get(widget.artistId),
- builder: (context, snapshot) {
- if (!snapshot.hasData) {
- return const Center(child: CircularProgressIndicator.adaptive());
- }
+ final avatarWidth = useBreakpointValue(
+ sm: MediaQuery.of(context).size.width * 0.50,
+ md: MediaQuery.of(context).size.width * 0.40,
+ lg: MediaQuery.of(context).size.width * 0.18,
+ xl: MediaQuery.of(context).size.width * 0.18,
+ xxl: MediaQuery.of(context).size.width * 0.18,
+ );
- return SingleChildScrollView(
- padding: const EdgeInsets.all(20),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- const SizedBox(width: 50),
- CircleAvatar(
- radius: MediaQuery.of(context).size.width * 0.18,
- backgroundImage: CachedNetworkImageProvider(
- imageToUrlString(snapshot.data!.images),
+ final breakpoint = useBreakpoints();
+
+ return SafeArea(
+ child: Scaffold(
+ appBar: const PageWindowTitleBar(
+ leading: BackButton(),
+ ),
+ body: FutureBuilder(
+ future: spotify.artists.get(artistId),
+ builder: (context, snapshot) {
+ if (!snapshot.hasData) {
+ return const Center(child: CircularProgressIndicator.adaptive());
+ }
+
+ 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(snapshot.data!.images),
+ ),
),
- ),
- Flexible(
- child: Padding(
+ Padding(
padding: const EdgeInsets.all(20),
child: Column(
+ mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
@@ -64,21 +87,24 @@ class _ArtistProfileState extends State {
color: Colors.blue,
borderRadius: BorderRadius.circular(50)),
child: Text(snapshot.data!.type!.toUpperCase(),
- style: Theme.of(context)
- .textTheme
- .headline6
- ?.copyWith(color: Colors.white)),
+ style: chipTextVariant?.copyWith(
+ color: Colors.white)),
),
Text(
snapshot.data!.name!,
- style: Theme.of(context).textTheme.headline2,
+ style: breakpoint.isSm
+ ? textTheme.headline4
+ : textTheme.headline2,
),
Text(
"${toReadableNumber(snapshot.data!.followers!.total!.toDouble())} followers",
- style: Theme.of(context).textTheme.headline5,
+ style: breakpoint.isSm
+ ? textTheme.bodyText1
+ : textTheme.headline5,
),
const SizedBox(height: 20),
Row(
+ mainAxisSize: MainAxisSize.min,
children: [
// TODO: Implement check if user follows this artist
// LIMITATION: spotify-dart lib
@@ -122,167 +148,170 @@ class _ArtistProfileState extends State {
],
),
),
- ),
- ],
- ),
- const SizedBox(height: 50),
- FutureBuilder>(
- future:
- spotify.artists.getTopTracks(snapshot.data!.id!, "US"),
- builder: (context, trackSnapshot) {
- if (!trackSnapshot.hasData) {
- return const Center(
- child: CircularProgressIndicator.adaptive());
- }
- Playback playback = context.watch();
- var isPlaylistPlaying =
- playback.currentPlaylist?.id == snapshot.data?.id;
- playPlaylist(List tracks, {Track? currentTrack}) {
- currentTrack ??= tracks.first;
- if (!isPlaylistPlaying) {
- playback.setCurrentPlaylist = CurrentPlaylist(
- tracks: tracks,
- id: snapshot.data!.id!,
- name: "${snapshot.data!.name!} To Tracks",
- thumbnail: imageToUrlString(snapshot.data?.images),
- );
- playback.setCurrentTrack = currentTrack;
- } else if (isPlaylistPlaying &&
- currentTrack.id != null &&
- currentTrack.id != playback.currentTrack?.id) {
- playback.setCurrentTrack = currentTrack;
+ ],
+ ),
+ const SizedBox(height: 50),
+ FutureBuilder>(
+ future:
+ spotify.artists.getTopTracks(snapshot.data!.id!, "US"),
+ builder: (context, trackSnapshot) {
+ if (!trackSnapshot.hasData) {
+ return const Center(
+ child: CircularProgressIndicator.adaptive());
+ }
+ Playback playback = ref.watch(playbackProvider);
+ var isPlaylistPlaying =
+ playback.currentPlaylist?.id == snapshot.data?.id;
+ playPlaylist(List tracks,
+ {Track? currentTrack}) async {
+ currentTrack ??= tracks.first;
+ if (!isPlaylistPlaying) {
+ playback.setCurrentPlaylist = CurrentPlaylist(
+ tracks: tracks,
+ id: snapshot.data!.id!,
+ name: "${snapshot.data!.name!} To Tracks",
+ thumbnail: imageToUrlString(snapshot.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),
+ return Column(children: [
+ Row(
+ children: [
+ Text(
+ "Top Tracks",
+ style: Theme.of(context).textTheme.headline4,
),
- child: IconButton(
- icon: Icon(isPlaylistPlaying
- ? Icons.stop_rounded
- : Icons.play_arrow_rounded),
- color: Colors.white,
- onPressed: trackSnapshot.hasData
- ? () =>
- playPlaylist(trackSnapshot.data!.toList())
- : null,
- ),
- )
- ],
- ),
- ...trackSnapshot.data
- ?.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 TracksTableView.buildTrackTile(
- context,
- playback,
- duration: duration,
- track: track,
- thumbnailUrl: thumbnailUrl,
- onTrackPlayButtonPressed: (currentTrack) =>
- playPlaylist(
- trackSnapshot.data!.toList(),
- currentTrack: track.value,
+ Container(
+ margin: const EdgeInsets.symmetric(horizontal: 5),
+ decoration: BoxDecoration(
+ color: Theme.of(context).primaryColor,
+ borderRadius: BorderRadius.circular(50),
),
- );
- }) ??
- [],
- ]);
- },
- ),
- const SizedBox(height: 50),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Text(
- "Albums",
- style: Theme.of(context).textTheme.headline4,
- ),
- TextButton(
- child: const Text("See All"),
- onPressed: () {
- Navigator.of(context).push(MaterialPageRoute(
- builder: (context) => ArtistAlbumView(
- widget.artistId,
- snapshot.data?.name ?? "KRTX",
+ child: IconButton(
+ icon: Icon(isPlaylistPlaying
+ ? Icons.stop_rounded
+ : Icons.play_arrow_rounded),
+ color: Colors.white,
+ onPressed: trackSnapshot.hasData
+ ? () => playPlaylist(
+ trackSnapshot.data!.toList())
+ : null,
+ ),
+ )
+ ],
+ ),
+ ...trackSnapshot.data
+ ?.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(
+ trackSnapshot.data!.toList(),
+ currentTrack: track.value,
+ ),
+ );
+ }) ??
+ [],
+ ]);
+ },
+ ),
+ 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: snapshot.data?.name ?? "KRTX",
+ );
+ },
+ )
+ ],
+ ),
+ const SizedBox(height: 10),
+ FutureBuilder>(
+ future: spotify.artists
+ .albums(snapshot.data!.id!)
+ .getPage(5, 0)
+ .then((al) => al.items?.toList() ?? []),
+ builder: (context, snapshot) {
+ if (!snapshot.hasData) {
+ return const Center(
+ child: CircularProgressIndicator.adaptive());
+ }
+ return Scrollbar(
+ controller: scrollController,
+ child: SingleChildScrollView(
+ controller: scrollController,
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: snapshot.data
+ ?.map((album) => AlbumCard(album))
+ .toList() ??
+ [],
),
- ));
- },
- )
- ],
- ),
- const SizedBox(height: 10),
- FutureBuilder>(
- future: spotify.artists
- .albums(snapshot.data!.id!)
- .getPage(5, 0)
- .then((al) => al.items?.toList() ?? []),
- builder: (context, snapshot) {
- if (!snapshot.hasData) {
- return const Center(
- child: CircularProgressIndicator.adaptive());
- }
- return Center(
- child: Wrap(
- spacing: 20,
- runSpacing: 20,
- children: snapshot.data
- ?.map((album) => AlbumCard(album))
- .toList() ??
- [],
- ),
- );
- },
- ),
- const SizedBox(height: 20),
- Text(
- "Fans also likes",
- style: Theme.of(context).textTheme.headline4,
- ),
- const SizedBox(height: 10),
- FutureBuilder>(
- future: spotify.artists.getRelatedArtists(widget.artistId),
- builder: (context, snapshot) {
- if (!snapshot.hasData) {
- return const Center(
- child: CircularProgressIndicator.adaptive());
- }
+ ),
+ );
+ },
+ ),
+ const SizedBox(height: 20),
+ Text(
+ "Fans also likes",
+ style: Theme.of(context).textTheme.headline4,
+ ),
+ const SizedBox(height: 10),
+ FutureBuilder>(
+ future: spotify.artists.getRelatedArtists(artistId),
+ builder: (context, snapshot) {
+ if (!snapshot.hasData) {
+ return const Center(
+ child: CircularProgressIndicator.adaptive());
+ }
- return Center(
- child: Wrap(
- spacing: 20,
- runSpacing: 20,
- children: snapshot.data
- ?.map((artist) => ArtistCard(artist))
- .toList() ??
- [],
- ),
- );
- },
- )
- ],
- ),
- );
- },
+ return Center(
+ child: Wrap(
+ spacing: 20,
+ runSpacing: 20,
+ children: snapshot.data
+ ?.map((artist) => ArtistCard(artist))
+ .toList() ??
+ [],
+ ),
+ );
+ },
+ )
+ ],
+ ),
+ );
+ },
+ ),
),
);
}
diff --git a/lib/components/Category/CategoryCard.dart b/lib/components/Category/CategoryCard.dart
index 1788fffc..fec5085b 100644
--- a/lib/components/Category/CategoryCard.dart
+++ b/lib/components/Category/CategoryCard.dart
@@ -1,11 +1,13 @@
import 'package:flutter/material.dart' hide Page;
-import 'package:provider/provider.dart';
+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/Playlist/PlaylistCard.dart';
-import 'package:spotube/components/Playlist/PlaylistGenreView.dart';
+import 'package:spotube/hooks/usePagingController.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class CategoryCard extends StatefulWidget {
+class CategoryCard extends HookWidget {
final Category category;
final Iterable? playlists;
const CategoryCard(
@@ -14,11 +16,6 @@ class CategoryCard extends StatefulWidget {
this.playlists,
}) : super(key: key);
- @override
- _CategoryCardState createState() => _CategoryCardState();
-}
-
-class _CategoryCardState extends State {
@override
Widget build(BuildContext context) {
return Column(
@@ -26,59 +23,81 @@ class _CategoryCardState extends State {
Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
- widget.category.name ?? "Unknown",
+ category.name ?? "Unknown",
style: Theme.of(context).textTheme.headline5,
),
- TextButton(
- onPressed: () {
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (context) {
- return PlaylistGenreView(
- widget.category.id!,
- widget.category.name!,
- playlists: widget.playlists,
- );
- },
- ),
- );
- },
- child: const Text("See all"),
- )
],
),
),
- Consumer(
- builder: (context, data, child) {
- return FutureBuilder>(
- future: widget.playlists == null
- ? (widget.category.id != "user-featured-playlists"
- ? data.spotifyApi.playlists
- .getByCategoryId(widget.category.id!)
- : data.spotifyApi.playlists.featured)
- .getPage(4, 0)
- .then((value) => value.items ?? [])
- : Future.value(widget.playlists),
- builder: (context, snapshot) {
- if (snapshot.hasError) {
- return const Center(child: Text("Error occurred"));
+ HookConsumer(
+ builder: (context, ref, child) {
+ SpotifyApi spotifyApi = ref.watch(spotifyProvider);
+ final scrollController = useScrollController();
+ final pagingController =
+ usePagingController(firstPageKey: 0);
+
+ final _error = useState(false);
+ final mounted = useIsMounted();
+
+ useEffect(() {
+ listener(pageKey) async {
+ try {
+ if (playlists != null &&
+ playlists?.isNotEmpty == true &&
+ mounted()) {
+ return pagingController.appendLastPage(playlists!.toList());
}
- if (!snapshot.hasData) {
- return const Center(
- child: CircularProgressIndicator.adaptive(),
- );
+ final Page page = await (category.id !=
+ "user-featured-playlists"
+ ? spotifyApi.playlists.getByCategoryId(category.id!)
+ : spotifyApi.playlists.featured)
+ .getPage(3, pageKey);
+
+ if (!mounted()) return;
+ if (page.isLast && page.items != null) {
+ pagingController.appendLastPage(page.items!.toList());
+ } else if (page.items != null) {
+ pagingController.appendPage(
+ page.items!.toList(), page.nextOffset);
}
- return Wrap(
- spacing: 20,
- runSpacing: 20,
- children: snapshot.data!
- .map((playlist) => PlaylistCard(playlist))
- .toList(),
- );
- });
+ if (_error.value) _error.value = false;
+ } catch (e, stack) {
+ if (mounted()) {
+ if (!_error.value) _error.value = true;
+ pagingController.error = e;
+ }
+ print(
+ "[CategoryCard.pagingController.addPageRequestListener] $e");
+ print(stack);
+ }
+ }
+
+ pagingController.addPageRequestListener(listener);
+ return () {
+ pagingController.removePageRequestListener(listener);
+ };
+ }, [_error]);
+
+ if (_error.value) return const Text("Something Went Wrong");
+ return SizedBox(
+ height: 245,
+ child: Scrollbar(
+ controller: scrollController,
+ child: PagedListView(
+ shrinkWrap: true,
+ pagingController: pagingController,
+ scrollController: scrollController,
+ scrollDirection: Axis.horizontal,
+ builderDelegate: PagedChildBuilderDelegate(
+ itemBuilder: (context, playlist, index) {
+ return PlaylistCard(playlist);
+ },
+ ),
+ ),
+ ),
+ );
},
)
],
diff --git a/lib/components/Home.dart b/lib/components/Home.dart
deleted file mode 100644
index 1a4be44d..00000000
--- a/lib/components/Home.dart
+++ /dev/null
@@ -1,273 +0,0 @@
-import 'dart:io';
-
-import 'package:bitsdojo_window/bitsdojo_window.dart';
-import 'package:cached_network_image/cached_network_image.dart';
-import 'package:flutter/material.dart' hide Page;
-import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
-import 'package:provider/provider.dart';
-import 'package:shared_preferences/shared_preferences.dart';
-import 'package:oauth2/oauth2.dart' show AuthorizationException;
-import 'package:spotify/spotify.dart' hide Image, Player, Search;
-import 'package:spotube/components/Category/CategoryCard.dart';
-import 'package:spotube/components/Login.dart';
-import 'package:spotube/components/Lyrics.dart';
-import 'package:spotube/components/Search/Search.dart';
-import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
-import 'package:spotube/components/Player/Player.dart';
-import 'package:spotube/components/Settings.dart';
-import 'package:spotube/components/Library/UserLibrary.dart';
-import 'package:spotube/helpers/image-to-url-string.dart';
-import 'package:spotube/helpers/oauth-login.dart';
-import 'package:spotube/models/LocalStorageKeys.dart';
-import 'package:spotube/models/sideBarTiles.dart';
-import 'package:spotube/provider/Auth.dart';
-import 'package:spotube/provider/SpotifyDI.dart';
-
-List spotifyScopes = [
- "user-library-read",
- "user-library-modify",
- "user-read-private",
- "user-read-email",
- "user-follow-read",
- "user-follow-modify",
- "playlist-read-collaborative"
-];
-
-class Home extends StatefulWidget {
- const Home({Key? key}) : super(key: key);
-
- @override
- _HomeState createState() => _HomeState();
-}
-
-class _HomeState extends State {
- final PagingController _pagingController =
- PagingController(firstPageKey: 0);
-
- int _selectedIndex = 0;
-
- @override
- void initState() {
- super.initState();
- WidgetsBinding.instance?.addPostFrameCallback((timeStamp) async {
- SharedPreferences localStorage = await SharedPreferences.getInstance();
- String? clientId = localStorage.getString(LocalStorageKeys.clientId);
- String? clientSecret =
- localStorage.getString(LocalStorageKeys.clientSecret);
- String? accessToken =
- localStorage.getString(LocalStorageKeys.accessToken);
- String? refreshToken =
- localStorage.getString(LocalStorageKeys.refreshToken);
- String? expirationStr =
- localStorage.getString(LocalStorageKeys.expiration);
- DateTime? expiration =
- expirationStr != null ? DateTime.parse(expirationStr) : null;
- try {
- Auth authProvider = context.read();
-
- if (clientId != null && clientSecret != null) {
- SpotifyApi spotifyApi = SpotifyApi(
- SpotifyApiCredentials(
- clientId,
- clientSecret,
- accessToken: accessToken,
- refreshToken: refreshToken,
- expiration: expiration,
- scopes: spotifyScopes,
- ),
- );
- SpotifyApiCredentials credentials = await spotifyApi.getCredentials();
- if (credentials.accessToken?.isNotEmpty ?? false) {
- authProvider.setAuthState(
- clientId: clientId,
- clientSecret: clientSecret,
- accessToken:
- credentials.accessToken, // accessToken can be new/refreshed
- refreshToken: refreshToken,
- expiration: credentials.expiration,
- isLoggedIn: true,
- );
- }
- }
- _pagingController.addPageRequestListener((pageKey) async {
- try {
- SpotifyDI data = context.read();
- Page categories = await data.spotifyApi.categories
- .list(country: "US")
- .getPage(15, pageKey);
-
- var items = categories.items!.toList();
- if (pageKey == 0) {
- Category category = Category();
- category.id = "user-featured-playlists";
- category.name = "Featured";
- items.insert(0, category);
- }
-
- if (categories.isLast && categories.items != null) {
- _pagingController.appendLastPage(items);
- } else if (categories.items != null) {
- _pagingController.appendPage(items, categories.nextOffset);
- }
- } catch (e) {
- _pagingController.error = e;
- }
- });
- } on AuthorizationException catch (e) {
- if (clientId != null && clientSecret != null) {
- oauthLogin(
- context,
- clientId: clientId,
- clientSecret: clientSecret,
- );
- }
- } catch (e, stack) {
- print("[Home.initState]: $e");
- print(stack);
- }
- });
- }
-
- @override
- void dispose() {
- _pagingController.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- Auth authProvider = Provider.of(context);
- if (!authProvider.isLoggedIn) {
- return const Login();
- }
-
- return Scaffold(
- body: Column(
- children: [
- WindowTitleBarBox(
- child: Row(
- children: [
- Expanded(
- child: Row(
- children: [
- Container(
- constraints: const BoxConstraints(maxWidth: 256),
- color:
- Theme.of(context).navigationRailTheme.backgroundColor,
- child: MoveWindow(),
- ),
- Expanded(child: MoveWindow()),
- if (!Platform.isMacOS) const TitleBarActionButtons(),
- ],
- )),
- ],
- ),
- ),
- Expanded(
- child: Row(
- children: [
- NavigationRail(
- destinations: sidebarTileList
- .map((e) => NavigationRailDestination(
- icon: Icon(e.icon),
- label: Text(
- e.title,
- style: const TextStyle(
- fontWeight: FontWeight.bold,
- fontSize: 16,
- ),
- ),
- ))
- .toList(),
- selectedIndex: _selectedIndex,
- onDestinationSelected: (value) => setState(() {
- _selectedIndex = value;
- }),
- extended: true,
- leading: Padding(
- padding: const EdgeInsets.only(left: 15),
- child: Row(children: [
- Image.asset(
- "assets/spotube-logo.png",
- height: 50,
- width: 50,
- ),
- const SizedBox(
- width: 10,
- ),
- Text("Spotube",
- style: Theme.of(context).textTheme.headline4),
- ]),
- ),
- trailing:
- Consumer(builder: (context, data, widget) {
- return FutureBuilder(
- future: data.spotifyApi.me.get(),
- builder: (context, snapshot) {
- var avatarImg = imageToUrlString(snapshot.data?.images,
- index: (snapshot.data?.images?.length ?? 1) - 1);
- return Padding(
- padding: const EdgeInsets.all(16),
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- Row(
- children: [
- CircleAvatar(
- backgroundImage:
- CachedNetworkImageProvider(avatarImg),
- ),
- const SizedBox(width: 10),
- Text(
- snapshot.data?.displayName ?? "User's name",
- style: const TextStyle(
- fontWeight: FontWeight.bold,
- ),
- ),
- ],
- ),
- IconButton(
- icon: const Icon(Icons.settings_outlined),
- onPressed: () {
- Navigator.of(context)
- .push(MaterialPageRoute(
- builder: (context) {
- return const Settings();
- },
- ));
- }),
- ],
- ),
- );
- },
- );
- }),
- ),
- // contents of the spotify
- if (_selectedIndex == 0)
- Expanded(
- child: Padding(
- padding: const EdgeInsets.all(8.0),
- child: PagedListView(
- pagingController: _pagingController,
- builderDelegate: PagedChildBuilderDelegate(
- itemBuilder: (context, item, index) {
- return CategoryCard(item);
- },
- ),
- ),
- ),
- ),
- if (_selectedIndex == 1) const Search(),
- if (_selectedIndex == 2) const UserLibrary(),
- if (_selectedIndex == 3) const Lyrics(),
- ],
- ),
- ),
- // player itself
- const Player()
- ],
- ),
- );
- }
-}
diff --git a/lib/components/Home/Home.dart b/lib/components/Home/Home.dart
new file mode 100644
index 00000000..10fbec93
--- /dev/null
+++ b/lib/components/Home/Home.dart
@@ -0,0 +1,220 @@
+import 'dart:io';
+
+import 'package:bitsdojo_window/bitsdojo_window.dart';
+import 'package:flutter/material.dart' hide Page;
+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:oauth2/oauth2.dart' show AuthorizationException;
+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/Login.dart';
+import 'package:spotube/components/Lyrics.dart';
+import 'package:spotube/components/Search/Search.dart';
+import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
+import 'package:spotube/components/Player/Player.dart';
+import 'package:spotube/components/Library/UserLibrary.dart';
+import 'package:spotube/helpers/oauth-login.dart';
+import 'package:spotube/hooks/useBreakpointValue.dart';
+import 'package:spotube/hooks/useHotKeys.dart';
+import 'package:spotube/hooks/usePagingController.dart';
+import 'package:spotube/hooks/useSharedPreferences.dart';
+import 'package:spotube/models/LocalStorageKeys.dart';
+import 'package:spotube/provider/Auth.dart';
+import 'package:spotube/provider/SpotifyDI.dart';
+
+List spotifyScopes = [
+ "user-library-read",
+ "user-library-modify",
+ "user-read-private",
+ "user-read-email",
+ "user-follow-read",
+ "user-follow-modify",
+ "playlist-read-collaborative"
+];
+
+class Home extends HookConsumerWidget {
+ const Home({Key? key}) : super(key: key);
+
+ @override
+ Widget build(BuildContext context, ref) {
+ Auth auth = ref.watch(authProvider);
+
+ final pagingController =
+ usePagingController(firstPageKey: 0);
+ final int titleBarDragMaxWidth = useBreakpointValue(
+ md: 72,
+ lg: 256,
+ sm: 0,
+ xl: 0,
+ xxl: 0,
+ );
+ final _selectedIndex = useState(0);
+ _onSelectedIndexChanged(int index) => _selectedIndex.value = index;
+
+ final localStorage = useSharedPreferences();
+
+ // initializing global hot keys
+ useHotKeys(ref);
+
+ useEffect(() {
+ if (localStorage == null) return null;
+ final String? clientId =
+ localStorage.getString(LocalStorageKeys.clientId);
+ final String? clientSecret =
+ localStorage.getString(LocalStorageKeys.clientSecret);
+ final String? accessToken =
+ localStorage.getString(LocalStorageKeys.accessToken);
+ final String? refreshToken =
+ localStorage.getString(LocalStorageKeys.refreshToken);
+ final String? expirationStr =
+ localStorage.getString(LocalStorageKeys.expiration);
+ listener(pageKey) async {
+ final spotify = ref.read(spotifyProvider);
+ try {
+ Page categories =
+ await spotify.categories.list(country: "US").getPage(15, pageKey);
+
+ var items = categories.items!.toList();
+ if (pageKey == 0) {
+ Category category = Category();
+ category.id = "user-featured-playlists";
+ category.name = "Featured";
+ items.insert(0, category);
+ }
+
+ if (categories.isLast && categories.items != null) {
+ pagingController.appendLastPage(items);
+ } else if (categories.items != null) {
+ pagingController.appendPage(items, categories.nextOffset);
+ }
+ } catch (e, stack) {
+ pagingController.error = e;
+ print("[Home.pagingController.addPageRequestListener] $e");
+ print(stack);
+ }
+ }
+
+ try {
+ final DateTime? expiration =
+ expirationStr != null ? DateTime.parse(expirationStr) : null;
+ if (clientId != null && clientSecret != null) {
+ SpotifyApi spotify = SpotifyApi(
+ SpotifyApiCredentials(
+ clientId,
+ clientSecret,
+ accessToken: accessToken,
+ refreshToken: refreshToken,
+ expiration: expiration,
+ scopes: spotifyScopes,
+ ),
+ );
+ spotify.getCredentials().then((credentials) {
+ if (credentials.accessToken?.isNotEmpty ?? false) {
+ auth.setAuthState(
+ clientId: clientId,
+ clientSecret: clientSecret,
+ accessToken:
+ credentials.accessToken, // accessToken can be new/refreshed
+ refreshToken: refreshToken,
+ expiration: credentials.expiration,
+ isLoggedIn: true,
+ );
+ }
+ return null;
+ }).then((_) {
+ pagingController.addPageRequestListener(listener);
+ }).catchError((e, stack) {
+ if (e is AuthorizationException) {
+ oauthLogin(
+ auth,
+ clientId: clientId,
+ clientSecret: clientSecret,
+ );
+ }
+ print("[Home.useEffect.spotify.getCredentials]: $e");
+ print(stack);
+ });
+ }
+ } catch (e, stack) {
+ print("[Home.initState]: $e");
+ print(stack);
+ }
+ return () {
+ pagingController.removePageRequestListener(listener);
+ };
+ }, [localStorage]);
+
+ if (!auth.isLoggedIn) {
+ return const Login();
+ }
+
+ return SafeArea(
+ child: Scaffold(
+ body: Column(
+ children: [
+ WindowTitleBarBox(
+ child: Row(
+ children: [
+ Expanded(
+ child: Row(
+ children: [
+ Container(
+ constraints: BoxConstraints(
+ maxWidth: titleBarDragMaxWidth.toDouble(),
+ ),
+ color: Theme.of(context)
+ .navigationRailTheme
+ .backgroundColor,
+ child: MoveWindow(),
+ ),
+ Expanded(child: MoveWindow()),
+ if (!Platform.isMacOS) const TitleBarActionButtons(),
+ ],
+ ))
+ ],
+ ),
+ ),
+ Expanded(
+ child: Row(
+ children: [
+ Sidebar(
+ selectedIndex: _selectedIndex.value,
+ onSelectedIndexChanged: _onSelectedIndexChanged,
+ ),
+ // contents of the spotify
+ if (_selectedIndex.value == 0)
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: PagedListView(
+ pagingController: pagingController,
+ builderDelegate: PagedChildBuilderDelegate(
+ itemBuilder: (context, item, index) {
+ return CategoryCard(item);
+ },
+ ),
+ ),
+ ),
+ ),
+ if (_selectedIndex.value == 1) const Search(),
+ if (_selectedIndex.value == 2) const UserLibrary(),
+ if (_selectedIndex.value == 3) const Lyrics(),
+ ],
+ ),
+ ),
+ // player itself
+ const Player(),
+ SpotubeNavigationBar(
+ selectedIndex: _selectedIndex.value,
+ onSelectedIndexChanged: _onSelectedIndexChanged,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/components/Home/Sidebar.dart b/lib/components/Home/Sidebar.dart
new file mode 100644
index 00000000..da77a107
--- /dev/null
+++ b/lib/components/Home/Sidebar.dart
@@ -0,0 +1,123 @@
+import 'package:cached_network_image/cached_network_image.dart';
+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/provider/SpotifyDI.dart';
+
+import '../../models/sideBarTiles.dart';
+
+class Sidebar extends HookConsumerWidget {
+ final int selectedIndex;
+ final void Function(int) onSelectedIndexChanged;
+
+ const Sidebar({
+ required this.selectedIndex,
+ required this.onSelectedIndexChanged,
+ Key? key,
+ }) : super(key: key);
+
+ Widget _buildSmallLogo() {
+ return Image.asset(
+ "assets/spotube-logo.png",
+ height: 50,
+ width: 50,
+ );
+ }
+
+ static void goToSettings(BuildContext context) {
+ GoRouter.of(context).push("/settings");
+ }
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final breakpoints = useBreakpoints();
+ if (breakpoints.isSm) return Container();
+ final extended = useState(false);
+ final SpotifyApi spotify = ref.watch(spotifyProvider);
+
+ useEffect(() {
+ if (breakpoints.isMd && extended.value) {
+ extended.value = false;
+ } else if (breakpoints.isMoreThanOrEqualTo(Breakpoints.lg) &&
+ !extended.value) {
+ extended.value = true;
+ }
+ return null;
+ });
+
+ return NavigationRail(
+ destinations: sidebarTileList
+ .map(
+ (e) => NavigationRailDestination(
+ icon: Icon(e.icon),
+ label: Text(
+ e.title,
+ style: const TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: 16,
+ ),
+ ),
+ ),
+ )
+ .toList(),
+ selectedIndex: selectedIndex,
+ onDestinationSelected: onSelectedIndexChanged,
+ extended: extended.value,
+ leading: extended.value
+ ? Padding(
+ padding: const EdgeInsets.only(left: 15),
+ child: Row(children: [
+ _buildSmallLogo(),
+ const SizedBox(
+ width: 10,
+ ),
+ Text("Spotube", style: Theme.of(context).textTheme.headline4),
+ ]),
+ )
+ : _buildSmallLogo(),
+ trailing: FutureBuilder(
+ future: spotify.me.get(),
+ builder: (context, snapshot) {
+ var avatarImg = imageToUrlString(snapshot.data?.images,
+ index: (snapshot.data?.images?.length ?? 1) - 1);
+ return extended.value
+ ? Padding(
+ padding: const EdgeInsets.all(16),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Row(
+ children: [
+ CircleAvatar(
+ backgroundImage:
+ CachedNetworkImageProvider(avatarImg),
+ ),
+ const SizedBox(width: 10),
+ Text(
+ snapshot.data?.displayName ?? "User's name",
+ style: const TextStyle(
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ],
+ ),
+ IconButton(
+ icon: const Icon(Icons.settings_outlined),
+ onPressed: () => goToSettings(context)),
+ ],
+ ))
+ : InkWell(
+ onTap: () => goToSettings(context),
+ child: CircleAvatar(
+ backgroundImage: CachedNetworkImageProvider(avatarImg),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
diff --git a/lib/components/Home/SpotubeNavigationBar.dart b/lib/components/Home/SpotubeNavigationBar.dart
new file mode 100644
index 00000000..158aad5c
--- /dev/null
+++ b/lib/components/Home/SpotubeNavigationBar.dart
@@ -0,0 +1,42 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:spotube/components/Home/Sidebar.dart';
+import 'package:spotube/hooks/useBreakpoints.dart';
+import 'package:spotube/models/sideBarTiles.dart';
+
+class SpotubeNavigationBar extends HookWidget {
+ final int selectedIndex;
+ final void Function(int) onSelectedIndexChanged;
+
+ const SpotubeNavigationBar({
+ required this.selectedIndex,
+ required this.onSelectedIndexChanged,
+ Key? key,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ final breakpoint = useBreakpoints();
+
+ if (breakpoint.isMoreThan(Breakpoints.sm)) return Container();
+ return NavigationBar(
+ destinations: [
+ ...sidebarTileList.map(
+ (e) => NavigationDestination(icon: Icon(e.icon), label: e.title),
+ ),
+ const NavigationDestination(
+ icon: Icon(Icons.settings_rounded),
+ label: "Settings",
+ )
+ ],
+ selectedIndex: selectedIndex,
+ onDestinationSelected: (i) {
+ if (i == 4) {
+ Sidebar.goToSettings(context);
+ } else {
+ onSelectedIndexChanged(i);
+ }
+ },
+ );
+ }
+}
diff --git a/lib/components/Library/UserArtists.dart b/lib/components/Library/UserArtists.dart
index 60291c33..17fae18e 100644
--- a/lib/components/Library/UserArtists.dart
+++ b/lib/components/Library/UserArtists.dart
@@ -1,18 +1,18 @@
import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
-import 'package:provider/provider.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Artist/ArtistCard.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class UserArtists extends StatefulWidget {
+class UserArtists extends ConsumerStatefulWidget {
const UserArtists({Key? key}) : super(key: key);
@override
- State createState() => _UserArtistsState();
+ ConsumerState createState() => _UserArtistsState();
}
-class _UserArtistsState extends State {
+class _UserArtistsState extends ConsumerState {
final PagingController _pagingController =
PagingController(firstPageKey: "");
@@ -22,8 +22,8 @@ class _UserArtistsState extends State {
WidgetsBinding.instance?.addPostFrameCallback((timestamp) {
_pagingController.addPageRequestListener((pageKey) async {
try {
- SpotifyDI data = context.read();
- CursorPage artists = await data.spotifyApi.me
+ SpotifyApi spotifyApi = ref.read(spotifyProvider);
+ CursorPage artists = await spotifyApi.me
.following(FollowingType.artist)
.getPage(15, pageKey);
@@ -51,10 +51,10 @@ class _UserArtistsState extends State {
@override
Widget build(BuildContext context) {
- SpotifyDI data = context.watch();
+ SpotifyApi spotifyApi = ref.watch(spotifyProvider);
return FutureBuilder>(
- future: data.spotifyApi.me.following(FollowingType.artist).first(),
+ future: spotifyApi.me.following(FollowingType.artist).first(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator.adaptive());
diff --git a/lib/components/Library/UserLibrary.dart b/lib/components/Library/UserLibrary.dart
index 415764cc..09f56a44 100644
--- a/lib/components/Library/UserLibrary.dart
+++ b/lib/components/Library/UserLibrary.dart
@@ -2,14 +2,8 @@ import 'package:flutter/material.dart' hide Image;
import 'package:spotube/components/Library/UserArtists.dart';
import 'package:spotube/components/Library/UserPlaylists.dart';
-class UserLibrary extends StatefulWidget {
+class UserLibrary extends StatelessWidget {
const UserLibrary({Key? key}) : super(key: key);
-
- @override
- _UserLibraryState createState() => _UserLibraryState();
-}
-
-class _UserLibraryState extends State {
@override
Widget build(BuildContext context) {
return Expanded(
diff --git a/lib/components/Library/UserPlaylists.dart b/lib/components/Library/UserPlaylists.dart
index dbe37fbe..ce6076c0 100644
--- a/lib/components/Library/UserPlaylists.dart
+++ b/lib/components/Library/UserPlaylists.dart
@@ -1,18 +1,18 @@
import 'package:flutter/material.dart' hide Image;
-import 'package:provider/provider.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Playlist/PlaylistCard.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class UserPlaylists extends StatelessWidget {
+class UserPlaylists extends ConsumerWidget {
const UserPlaylists({Key? key}) : super(key: key);
@override
- Widget build(BuildContext context) {
- SpotifyDI data = context.watch();
+ Widget build(BuildContext context, ref) {
+ SpotifyApi spotifyApi = ref.watch(spotifyProvider);
return FutureBuilder>(
- future: data.spotifyApi.playlists.me.all(),
+ future: spotifyApi.playlists.me.all(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator.adaptive());
diff --git a/lib/components/Login.dart b/lib/components/Login.dart
index e7bb4427..f5d94a1b 100644
--- a/lib/components/Login.dart
+++ b/lib/components/Login.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/components/Shared/Hyperlink.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
@@ -8,127 +9,109 @@ import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart';
import 'package:spotube/provider/UserPreferences.dart';
-class Login extends StatefulWidget {
+class Login extends HookConsumerWidget {
const Login({Key? key}) : super(key: key);
@override
- _LoginState createState() => _LoginState();
-}
+ Widget build(BuildContext context, ref) {
+ var clientIdController = useTextEditingController();
+ var clientSecretController = useTextEditingController();
+ var accessTokenController = useTextEditingController();
+ var fieldError = useState(false);
-class _LoginState extends State {
- String clientId = "";
- String clientSecret = "";
- String accessToken = "";
- bool _fieldError = false;
-
- Future handleLogin(Auth authState) async {
- try {
- if (clientId == "" || clientSecret == "") {
- return setState(() {
- _fieldError = true;
- });
- }
- await oauthLogin(context, clientId: clientId, clientSecret: clientSecret);
- } catch (e) {
- print("[Login.handleLogin] $e");
- }
- }
-
- @override
- Widget build(BuildContext context) {
- return Consumer(
- builder: (context, authState, child) {
- return Scaffold(
- appBar: const PageWindowTitleBar(),
- body: SingleChildScrollView(
- child: Center(
- child: Column(
- children: [
- Image.asset(
- "assets/spotube-logo.png",
- width: 400,
- height: 400,
- ),
- Text("Add your spotify credentials to get started",
- style: Theme.of(context).textTheme.headline4),
- const Text(
- "Don't worry, any of your credentials won't be collected or shared with anyone"),
- const Hyperlink("How to get these client-id & client-secret?",
- "https://github.com/KRTirtho/spotube#configuration"),
- const SizedBox(
- height: 10,
- ),
- Container(
- constraints: const BoxConstraints(
- maxWidth: 400,
- ),
- child: Column(
- children: [
- TextField(
- decoration: const InputDecoration(
- hintText: "Spotify Client ID",
- label: Text("ClientID"),
- ),
- onChanged: (value) {
- setState(() {
- clientId = value;
- });
- },
- ),
- const SizedBox(height: 10),
- TextField(
- decoration: const InputDecoration(
- hintText: "Spotify Client Secret",
- label: Text("Client Secret"),
- ),
- onChanged: (value) {
- setState(() {
- clientSecret = value;
- });
- },
- ),
- const SizedBox(height: 10),
- const Divider(color: Colors.grey),
- const SizedBox(height: 10),
- TextField(
- decoration: const InputDecoration(
- label: Text("Genius Access Token (optional)"),
- ),
- onChanged: (value) {
- setState(() {
- accessToken = value;
- });
- },
- ),
- const SizedBox(
- height: 10,
- ),
- ElevatedButton(
- onPressed: () async {
- await handleLogin(authState);
- UserPreferences preferences =
- context.read();
- SharedPreferences localStorage =
- await SharedPreferences.getInstance();
- preferences.setGeniusAccessToken(accessToken);
- await localStorage.setString(
- LocalStorageKeys.geniusAccessToken,
- accessToken);
- setState(() {
- accessToken = "";
- });
- },
- child: const Text("Submit"),
- )
- ],
- ),
- ),
- ],
- ),
- ),
- ),
+ Future handleLogin(Auth authState) async {
+ try {
+ if (clientIdController.value.text == "" ||
+ clientSecretController.value.text == "") {
+ fieldError.value = true;
+ }
+ await oauthLogin(
+ ref.read(authProvider),
+ clientId: clientIdController.value.text,
+ clientSecret: clientSecretController.value.text,
);
- },
+ } catch (e) {
+ print("[Login.handleLogin] $e");
+ }
+ }
+
+ Auth authState = ref.watch(authProvider);
+ return Scaffold(
+ appBar: const PageWindowTitleBar(),
+ body: SingleChildScrollView(
+ child: Center(
+ child: Column(
+ children: [
+ Image.asset(
+ "assets/spotube-logo.png",
+ width: 400,
+ height: 400,
+ ),
+ Text("Add your spotify credentials to get started",
+ style: Theme.of(context).textTheme.headline4),
+ const Text(
+ "Don't worry, any of your credentials won't be collected or shared with anyone"),
+ const Hyperlink("How to get these client-id & client-secret?",
+ "https://github.com/KRTirtho/spotube#configuration"),
+ const SizedBox(
+ height: 10,
+ ),
+ Container(
+ constraints: const BoxConstraints(
+ maxWidth: 400,
+ ),
+ child: Column(
+ children: [
+ TextField(
+ controller: clientIdController,
+ decoration: const InputDecoration(
+ hintText: "Spotify Client ID",
+ label: Text("ClientID"),
+ ),
+ ),
+ const SizedBox(height: 10),
+ TextField(
+ decoration: const InputDecoration(
+ hintText: "Spotify Client Secret",
+ label: Text("Client Secret"),
+ ),
+ controller: clientSecretController,
+ ),
+ const SizedBox(height: 10),
+ const Divider(color: Colors.grey),
+ const SizedBox(height: 10),
+ TextField(
+ decoration: const InputDecoration(
+ label: Text("Genius Access Token (optional)"),
+ ),
+ controller: accessTokenController,
+ ),
+ const SizedBox(
+ height: 10,
+ ),
+ ElevatedButton(
+ onPressed: () async {
+ await handleLogin(authState);
+ UserPreferences preferences =
+ ref.read(userPreferencesProvider);
+ SharedPreferences localStorage =
+ await SharedPreferences.getInstance();
+ preferences.setGeniusAccessToken(
+ accessTokenController.value.text);
+ await localStorage.setString(
+ LocalStorageKeys.geniusAccessToken,
+ accessTokenController.value.text);
+ accessTokenController.text = "";
+ },
+ child: const Text("Submit"),
+ )
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
);
}
}
diff --git a/lib/components/Lyrics.dart b/lib/components/Lyrics.dart
index dcc5b19a..16cf56f5 100644
--- a/lib/components/Lyrics.dart
+++ b/lib/components/Lyrics.dart
@@ -1,54 +1,62 @@
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Settings.dart';
+import 'package:spotube/components/Shared/SpotubePageRoute.dart';
import 'package:spotube/helpers/artist-to-string.dart';
import 'package:spotube/helpers/getLyrics.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/UserPreferences.dart';
-class Lyrics extends StatefulWidget {
+class Lyrics extends HookConsumerWidget {
const Lyrics({Key? key}) : super(key: key);
@override
- State createState() => _LyricsState();
-}
-
-class _LyricsState extends State {
- Map _lyrics = {};
-
- @override
- Widget build(BuildContext context) {
- Playback playback = context.watch();
- UserPreferences userPreferences = context.watch();
+ Widget build(BuildContext context, ref) {
+ Playback playback = ref.watch(playbackProvider);
+ UserPreferences userPreferences = ref.watch(userPreferencesProvider);
+ var lyrics = useState({});
bool hasToken = (userPreferences.geniusAccessToken != null ||
(userPreferences.geniusAccessToken?.isNotEmpty ?? false));
-
- if (playback.currentTrack != null &&
- hasToken &&
- playback.currentTrack!.id != _lyrics["id"]) {
- getLyrics(
+ var lyricsFuture = useMemoized(() {
+ if (playback.currentTrack == null ||
+ !hasToken ||
+ (playback.currentTrack?.id != null &&
+ playback.currentTrack?.id == lyrics.value["id"])) {
+ return null;
+ }
+ return getLyrics(
playback.currentTrack!.name!,
artistsToString(playback.currentTrack!.artists ?? []),
apiKey: userPreferences.geniusAccessToken!,
optimizeQuery: true,
- ).then((lyrics) {
- if (lyrics != null) {
- setState(() {
- _lyrics = {"lyrics": lyrics, "id": playback.currentTrack!.id!};
- });
- }
- });
- }
+ );
+ }, [playback.currentTrack]);
- if (_lyrics["lyrics"] != null && playback.currentTrack == null) {
- setState(() {
- _lyrics = {};
- });
- }
+ var lyricsSnapshot = useFuture(lyricsFuture);
- if (_lyrics["lyrics"] == null && playback.currentTrack != null) {
+ useEffect(() {
+ if (lyricsSnapshot.hasData && lyricsSnapshot.data != null) {
+ lyrics.value = {
+ "lyrics": lyricsSnapshot.data,
+ "id": playback.currentTrack!.id!
+ };
+ }
+
+ if (lyrics.value["lyrics"] != null && playback.currentTrack == null) {
+ lyrics.value = {};
+ }
+ }, [
+ lyricsSnapshot.data,
+ lyricsSnapshot.hasData,
+ lyrics.value,
+ playback.currentTrack,
+ ]);
+
+ if (lyrics.value["lyrics"] == null && playback.currentTrack != null) {
if (!hasToken) {
return Expanded(
child: Column(
@@ -62,11 +70,7 @@ class _LyricsState extends State {
),
ElevatedButton(
onPressed: () {
- Navigator.of(context).push(MaterialPageRoute(
- builder: (context) {
- return const Settings();
- },
- ));
+ GoRouter.of(context).push("/settings");
},
child: const Text("Add Access Token"))
],
@@ -99,9 +103,10 @@ class _LyricsState extends State {
child: SingleChildScrollView(
child: Center(
child: Text(
- _lyrics["lyrics"] == null && playback.currentTrack == null
+ lyrics.value["lyrics"] == null &&
+ playback.currentTrack == null
? "No Track being played currently"
- : _lyrics["lyrics"]!,
+ : lyrics.value["lyrics"]!,
style: Theme.of(context).textTheme.headline6,
),
),
diff --git a/lib/components/Player/Player.dart b/lib/components/Player/Player.dart
index ef9e4d2b..d179afb4 100644
--- a/lib/components/Player/Player.dart
+++ b/lib/components/Player/Player.dart
@@ -1,388 +1,185 @@
import 'dart:async';
-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:just_audio/just_audio.dart';
-import 'package:spotify/spotify.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:spotify/spotify.dart' hide Image;
+import 'package:spotube/components/Player/PlayerOverlay.dart';
+import 'package:spotube/components/Player/PlayerTrackDetails.dart';
import 'package:spotube/components/Shared/DownloadTrackButton.dart';
import 'package:spotube/components/Player/PlayerControls.dart';
-import 'package:spotube/helpers/artists-to-clickable-artists.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
-import 'package:spotube/helpers/search-youtube.dart';
+import 'package:spotube/hooks/useBreakpoints.dart';
+import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-import 'package:youtube_explode_dart/youtube_explode_dart.dart';
-class Player extends StatefulWidget {
+class Player extends HookConsumerWidget {
const Player({Key? key}) : super(key: key);
-
@override
- _PlayerState createState() => _PlayerState();
-}
+ Widget build(BuildContext context, ref) {
+ Playback playback = ref.watch(playbackProvider);
-class _PlayerState extends State with WidgetsBindingObserver {
- late AudioPlayer player;
- bool _isPlaying = false;
- bool _shuffled = false;
- Duration? _duration;
+ final _volume = useState(0.0);
- String? _currentTrackId;
+ final breakpoint = useBreakpoints();
- double _volume = 0;
+ final AudioPlayer player = playback.player;
- late YoutubeExplode youtube;
+ final Future future =
+ useMemoized(SharedPreferences.getInstance);
+ final AsyncSnapshot localStorage =
+ useFuture(future, initialData: null);
- @override
- void initState() {
- try {
- super.initState();
- player = AudioPlayer();
- youtube = YoutubeExplode();
+ useEffect(() {
+ /// warm up the audio player before playing actual audio
+ /// It's for resolving unresolved issue related to just_audio's
+ /// [disposeAllPlayers] method which is throwing
+ /// [UnimplementedException] in the [PlatformInterface]
+ /// implementation
+ player.setAsset("assets/warmer.mp3");
+ return null;
+ }, []);
- WidgetsBinding.instance?.addObserver(this);
- WidgetsBinding.instance?.addPostFrameCallback(_init);
- } catch (e, stack) {
- print("[Player.initState()] $e");
- print(stack);
- }
- }
+ useEffect(() {
+ if (localStorage.hasData) {
+ _volume.value = localStorage.data?.getDouble(LocalStorageKeys.volume) ??
+ player.volume;
+ }
+ return null;
+ }, [localStorage.data]);
- _init(Duration timeStamp) async {
- try {
- setState(() {
- _volume = player.volume;
- });
- player.playingStream.listen((playing) async {
- setState(() {
- _isPlaying = playing;
- });
- });
+ String albumArt = useMemoized(
+ () => imageToUrlString(
+ playback.currentTrack?.album?.images,
+ index: (playback.currentTrack?.album?.images?.length ?? 1) - 1,
+ ),
+ [playback.currentTrack?.album?.images],
+ );
- player.durationStream.listen((duration) async {
- if (duration != null) {
- // Actually things doesn't work all the time as they were
- // described. So instead of listening to a `playback.ready`
- // stream, it has to listen to duration stream since duration
- // is always added to the Stream sink after all icyMetadata has
- // been loaded thus indicating buffering started
- if (duration != Duration.zero && duration != _duration) {
- // this line is for prev/next or already playing playlist
- if (player.playing) await player.pause();
- await player.play();
- }
- setState(() {
- _duration = duration;
- });
- }
- });
+ final entryRef = useRef(null);
- player.processingStateStream.listen((event) async {
- try {
- if (event == ProcessingState.completed && _currentTrackId != null) {
- _movePlaylistPositionBy(1);
- }
- } catch (e, stack) {
- print("[PrecessingStateStreamListener] $e");
+ disposeOverlay() {
+ try {
+ entryRef.value?.remove();
+ entryRef.value = null;
+ } catch (e, stack) {
+ if (e is! AssertionError) {
+ print("[Player.useEffect.cleanup] $e");
print(stack);
}
- });
- } catch (e) {
- print("[Player._init()]: $e");
+ }
}
- }
- @override
- void dispose() {
- WidgetsBinding.instance?.removeObserver(this);
- player.dispose();
- youtube.close();
- super.dispose();
- }
-
- @override
- void didChangeAppLifecycleState(AppLifecycleState state) {
- if (state == AppLifecycleState.paused) {
- // Release the player's resources when not in use. We use "stop" so that
- // if the app resumes later, it will still remember what position to
- // resume from.
- player.stop();
- }
- }
-
- void _movePlaylistPositionBy(int pos) {
- Playback playback = context.read();
- if (playback.currentTrack != null && playback.currentPlaylist != null) {
- int index = playback.currentPlaylist!.trackIds
- .indexOf(playback.currentTrack!.id!) +
- pos;
-
- var safeIndex = index > playback.currentPlaylist!.trackIds.length - 1
- ? 0
- : index < 0
- ? playback.currentPlaylist!.trackIds.length
- : index;
- Track? track =
- playback.currentPlaylist!.tracks.asMap().containsKey(safeIndex)
- ? playback.currentPlaylist!.tracks.elementAt(safeIndex)
- : null;
- if (track != null) {
- playback.setCurrentTrack = track;
- setState(() {
- _duration = null;
+ useEffect(() {
+ // clearing the overlay-entry as passing the already available
+ // entry will result in splashing while resizing the window
+ if (entryRef.value != null) disposeOverlay();
+ if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) {
+ entryRef.value = OverlayEntry(
+ opaque: false,
+ builder: (context) => PlayerOverlay(albumArt: albumArt),
+ );
+ // I can't believe useEffect doesn't run Post Frame aka
+ // after rendering/painting the UI
+ // `My disappointment is immeasurable and my day is ruined` XD
+ WidgetsBinding.instance?.addPostFrameCallback((time) {
+ Overlay.of(context)?.insert(entryRef.value!);
});
}
- }
- }
+ return () {
+ disposeOverlay();
+ };
+ }, [breakpoint]);
- Future _playTrack(Track currentTrack, Playback playback) async {
- try {
- if (currentTrack.id != _currentTrackId) {
- if (currentTrack.uri != null) {
- await player
- .setAudioSource(
- AudioSource.uri(Uri.parse(currentTrack.uri!)),
- preload: true,
- )
- .then((value) async {
- setState(() {
- _currentTrackId = currentTrack.id;
- if (_duration != null) {
- _duration = value;
- }
- });
- });
- }
- var ytTrack = await toYoutubeTrack(youtube, currentTrack);
- if (playback.setTrackUriById(currentTrack.id!, ytTrack.uri!)) {
- await player
- .setAudioSource(AudioSource.uri(Uri.parse(ytTrack.uri!)))
- .then((value) {
- setState(() {
- _currentTrackId = currentTrack.id;
- });
- });
- }
- }
- } catch (e, stack) {
- print("[Player._playTrack()] $e");
- print(stack);
+ // returning an empty non spacious Container as the overlay will take
+ // place in the global overlay stack aka [_entries]
+ if (breakpoint.isLessThanOrEqualTo(Breakpoints.md)) {
+ return Container();
}
- }
- _onNext() async {
- try {
- await player.pause();
- await player.seek(Duration.zero);
- _movePlaylistPositionBy(1);
- print("ON NEXT");
- } catch (e, stack) {
- print("[PlayerControls.onNext()] $e");
- print(stack);
- }
- }
-
- _onPrevious() async {
- try {
- await player.pause();
- await player.seek(Duration.zero);
- _movePlaylistPositionBy(-1);
- } catch (e, stack) {
- print("[PlayerControls.onPrevious()] $e");
- print(stack);
- }
- }
-
- @override
- Widget build(BuildContext context) {
return Container(
color: Theme.of(context).backgroundColor,
- child: Consumer(
- builder: (context, playback, widget) {
- if (playback.currentPlaylist != null &&
- playback.currentTrack != null) {
- _playTrack(playback.currentTrack!, playback);
- }
-
- String? albumArt = imageToUrlString(
- playback.currentTrack?.album?.images,
- index: (playback.currentTrack?.album?.images?.length ?? 1) - 1,
- );
-
- return Material(
- type: MaterialType.transparency,
- child: Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
- children: [
- if (albumArt != null)
- CachedNetworkImage(
- imageUrl: albumArt,
- maxHeightDiskCache: 50,
- maxWidthDiskCache: 50,
- placeholder: (context, url) {
- return Container(
- height: 50,
- width: 50,
- color: Colors.green[400],
- );
- },
- ),
- // title of the currently playing track
- Flexible(
- flex: 1,
- child: Column(
- children: [
- Text(
- playback.currentTrack?.name ?? "Not playing",
- style: const TextStyle(fontWeight: FontWeight.bold),
- ),
- artistsToClickableArtists(
- playback.currentTrack?.artists ?? [],
- mainAxisAlignment: MainAxisAlignment.center,
- )
- ],
- ),
- ),
- // controls
- Flexible(
- flex: 3,
- child: PlayerControls(
- positionStream: player.positionStream,
- isPlaying: _isPlaying,
- duration: _duration ?? Duration.zero,
- shuffled: _shuffled,
- onNext: _onNext,
- onPrevious: _onPrevious,
- onPause: () async {
- try {
- await player.pause();
- } catch (e, stack) {
- print("[PlayerControls.onPause()] $e");
- print(stack);
- }
- },
- onPlay: () async {
- try {
- await player.play();
- } catch (e, stack) {
- print("[PlayerControls.onPlay()] $e");
- print(stack);
- }
- },
- onSeek: (value) async {
- try {
- await player.seek(Duration(seconds: value.toInt()));
- } catch (e, stack) {
- print("[PlayerControls.onSeek()] $e");
- print(stack);
- }
- },
- onShuffle: () async {
- if (playback.currentTrack == null ||
- playback.currentPlaylist == null) return;
- try {
- if (!_shuffled) {
- playback.currentPlaylist!.shuffle();
- setState(() {
- _shuffled = true;
- });
- } else {
- playback.currentPlaylist!.unshuffle();
- setState(() {
- _shuffled = false;
- });
- }
- } catch (e, stack) {
- print("[PlayerControls.onShuffle()] $e");
- print(stack);
- }
- },
- onStop: () async {
- try {
- await player.pause();
- await player.seek(Duration.zero);
- setState(() {
- _isPlaying = false;
- _currentTrackId = null;
- _duration = null;
- _shuffled = false;
- });
- playback.reset();
- } catch (e, stack) {
- print("[PlayerControls.onStop()] $e");
- print(stack);
- }
- },
- ),
- ),
- // add to saved tracks
- Expanded(
- flex: 1,
- child: Wrap(
- alignment: WrapAlignment.center,
- runAlignment: WrapAlignment.center,
- children: [
- Container(
- height: 20,
- constraints: const BoxConstraints(maxWidth: 200),
- child: Slider.adaptive(
- value: _volume,
- onChanged: (value) async {
- try {
- await player.setVolume(value).then((_) {
- setState(() {
- _volume = value;
- });
- });
- } catch (e, stack) {
- print("[VolumeSlider.onChange()] $e");
- print(stack);
- }
- },
- ),
- ),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- DownloadTrackButton(
- track: playback.currentTrack,
- ),
- Consumer(builder: (context, data, widget) {
- return FutureBuilder(
- future: playback.currentTrack?.id != null
- ? data.spotifyApi.tracks.me
- .containsOne(playback.currentTrack!.id!)
- : Future.value(false),
- initialData: false,
- builder: (context, snapshot) {
- bool isLiked = snapshot.data ?? false;
- return IconButton(
- icon: Icon(
- !isLiked
- ? Icons.favorite_outline_rounded
- : Icons.favorite_rounded,
- color: isLiked ? Colors.green : null,
- ),
- onPressed: () {
- if (!isLiked &&
- playback.currentTrack?.id != null) {
- data.spotifyApi.tracks.me
- .saveOne(
- playback.currentTrack!.id!)
- .then((value) => setState(() {}));
- }
- });
- });
- }),
- ],
- ),
- ],
- ),
- )
- ],
+ child: Material(
+ type: MaterialType.transparency,
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ Expanded(child: PlayerTrackDetails(albumArt: albumArt)),
+ // controls
+ const Expanded(
+ flex: 3,
+ child: PlayerControls(),
),
- );
- },
+ // add to saved tracks
+ Expanded(
+ flex: 1,
+ child: Wrap(
+ alignment: WrapAlignment.center,
+ runAlignment: WrapAlignment.center,
+ children: [
+ Container(
+ height: 20,
+ constraints: const BoxConstraints(maxWidth: 200),
+ child: Slider.adaptive(
+ value: _volume.value,
+ onChanged: (value) async {
+ try {
+ await player.setVolume(value).then((_) {
+ _volume.value = value;
+ localStorage.data?.setDouble(
+ LocalStorageKeys.volume,
+ value,
+ );
+ });
+ } catch (e, stack) {
+ print("[VolumeSlider.onChange()] $e");
+ print(stack);
+ }
+ },
+ ),
+ ),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ DownloadTrackButton(
+ track: playback.currentTrack,
+ ),
+ Consumer(builder: (context, ref, widget) {
+ SpotifyApi spotifyApi = ref.watch(spotifyProvider);
+ return FutureBuilder(
+ future: playback.currentTrack?.id != null
+ ? spotifyApi.tracks.me
+ .containsOne(playback.currentTrack!.id!)
+ : Future.value(false),
+ initialData: false,
+ builder: (context, snapshot) {
+ bool isLiked = snapshot.data ?? false;
+ return IconButton(
+ icon: Icon(
+ !isLiked
+ ? Icons.favorite_outline_rounded
+ : Icons.favorite_rounded,
+ color: isLiked ? Colors.green : null,
+ ),
+ onPressed: () {
+ if (!isLiked &&
+ playback.currentTrack?.id != null) {
+ spotifyApi.tracks.me
+ .saveOne(playback.currentTrack!.id!);
+ }
+ });
+ });
+ }),
+ ],
+ ),
+ ],
+ ),
+ )
+ ],
+ ),
),
);
}
diff --git a/lib/components/Player/PlayerControls.dart b/lib/components/Player/PlayerControls.dart
index adcefee9..376e1165 100644
--- a/lib/components/Player/PlayerControls.dart
+++ b/lib/components/Player/PlayerControls.dart
@@ -1,120 +1,67 @@
-import 'dart:async';
-
import 'package:flutter/material.dart';
-import 'package:hotkey_manager/hotkey_manager.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:just_audio/just_audio.dart';
import 'package:spotube/helpers/zero-pad-num-str.dart';
-import 'package:spotube/models/GlobalKeyActions.dart';
-import 'package:spotube/provider/UserPreferences.dart';
-import 'package:provider/provider.dart';
+import 'package:spotube/hooks/playback.dart';
+import 'package:spotube/provider/Playback.dart';
-class PlayerControls extends StatefulWidget {
- final Stream positionStream;
- final bool isPlaying;
- final Duration duration;
- final bool shuffled;
- final Function? onStop;
- final Function? onShuffle;
- final Function(double value)? onSeek;
- final Function? onNext;
- final Function? onPrevious;
- final Function? onPlay;
- final Function? onPause;
+class PlayerControls extends HookConsumerWidget {
+ final Color? iconColor;
const PlayerControls({
- required this.positionStream,
- required this.isPlaying,
- required this.duration,
- required this.shuffled,
- this.onShuffle,
- this.onStop,
- this.onSeek,
- this.onNext,
- this.onPrevious,
- this.onPlay,
- this.onPause,
+ this.iconColor,
Key? key,
}) : super(key: key);
@override
- _PlayerControlsState createState() => _PlayerControlsState();
-}
+ Widget build(BuildContext context, ref) {
+ final Playback playback = ref.watch(playbackProvider);
+ final AudioPlayer player = playback.player;
-class _PlayerControlsState extends State {
- StreamSubscription? _timePositionListener;
- late List _hotKeys = [];
+ final _shuffled = useState(false);
+ final _duration = useState(playback.duration);
- @override
- void dispose() async {
- await _timePositionListener?.cancel();
- Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey)));
- super.dispose();
- }
+ useEffect(() {
+ listener(Duration? duration) {
+ _duration.value = duration;
+ }
- _playOrPause(key) async {
- try {
- widget.isPlaying ? widget.onPause?.call() : await widget.onPlay?.call();
- } catch (e, stack) {
- print("[PlayPauseShortcut] $e");
- print(stack);
- }
- }
+ playback.addDurationChangeListener(listener);
- _configureHotKeys(UserPreferences preferences) async {
- await Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey)))
- .then((val) async {
- _hotKeys = [
- GlobalKeyActions(
- HotKey(KeyCode.space, scope: HotKeyScope.inapp),
- _playOrPause,
- ),
- if (preferences.nextTrackHotKey != null)
- GlobalKeyActions(
- preferences.nextTrackHotKey!, (key) => widget.onNext?.call()),
- if (preferences.prevTrackHotKey != null)
- GlobalKeyActions(
- preferences.prevTrackHotKey!, (key) => widget.onPrevious?.call()),
- if (preferences.playPauseHotKey != null)
- GlobalKeyActions(preferences.playPauseHotKey!, _playOrPause)
- ];
- await Future.wait(
- _hotKeys.map((e) {
- return hotKeyManager.register(
- e.hotKey,
- keyDownHandler: e.onKeyDown,
- );
- }),
- );
- });
- }
+ return () => playback.removeDurationChangeListener(listener);
+ }, []);
- @override
- Widget build(BuildContext context) {
- UserPreferences preferences = context.watch();
- _configureHotKeys(preferences);
+ final onNext = useNextTrack(playback);
+
+ final onPrevious = usePreviousTrack(playback);
+
+ final _playOrPause = useTogglePlayPause(playback);
+
+ final duration = _duration.value ?? Duration.zero;
return Container(
- constraints: const BoxConstraints(maxWidth: 700),
- child: Column(
- children: [
- StreamBuilder(
- stream: widget.positionStream,
- builder: (context, snapshot) {
- var totalMinutes =
- zeroPadNumStr(widget.duration.inMinutes.remainder(60));
- var totalSeconds =
- zeroPadNumStr(widget.duration.inSeconds.remainder(60));
- var currentMinutes = snapshot.hasData
- ? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60))
- : "00";
- var currentSeconds = snapshot.hasData
- ? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60))
- : "00";
+ constraints: const BoxConstraints(maxWidth: 700),
+ child: Column(
+ children: [
+ StreamBuilder(
+ stream: player.positionStream,
+ builder: (context, snapshot) {
+ final totalMinutes =
+ zeroPadNumStr(duration.inMinutes.remainder(60));
+ final totalSeconds =
+ zeroPadNumStr(duration.inSeconds.remainder(60));
+ final currentMinutes = snapshot.hasData
+ ? zeroPadNumStr(snapshot.data!.inMinutes.remainder(60))
+ : "00";
+ final currentSeconds = snapshot.hasData
+ ? zeroPadNumStr(snapshot.data!.inSeconds.remainder(60))
+ : "00";
- var sliderMax = widget.duration.inSeconds;
- var sliderValue = snapshot.data?.inSeconds ?? 0;
- return Row(
- children: [
- Expanded(
- child: Slider.adaptive(
+ final sliderMax = duration.inSeconds;
+ final sliderValue = snapshot.data?.inSeconds ?? 0;
+ return Column(
+ children: [
+ Slider.adaptive(
// cannot divide by zero
// there's an edge case for value being bigger
// than total duration. Keeping it resolved
@@ -123,50 +70,95 @@ class _PlayerControlsState extends State {
: sliderValue / sliderMax,
onChanged: (value) {},
onChangeEnd: (value) {
- widget.onSeek?.call(value * sliderMax);
+ player.seek(
+ Duration(
+ seconds: (value * sliderMax).toInt(),
+ ),
+ );
},
+ activeColor: iconColor,
),
- ),
- Text(
- "$currentMinutes:$currentSeconds/$totalMinutes:$totalSeconds",
- )
- ],
- );
- }),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceEvenly,
- children: [
- IconButton(
- icon: const Icon(Icons.shuffle_rounded),
- color:
- widget.shuffled ? Theme.of(context).primaryColor : null,
- onPressed: () {
- widget.onShuffle?.call();
- }),
- IconButton(
- icon: const Icon(Icons.skip_previous_rounded),
- onPressed: () {
- widget.onPrevious?.call();
- }),
- IconButton(
- icon: Icon(
- widget.isPlaying
- ? Icons.pause_rounded
- : Icons.play_arrow_rounded,
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 8.0),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Text(
+ "$currentMinutes:$currentSeconds",
+ ),
+ Text("$totalMinutes:$totalSeconds"),
+ ],
+ ),
+ ),
+ ],
+ );
+ }),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ IconButton(
+ icon: const Icon(Icons.shuffle_rounded),
+ color: _shuffled.value
+ ? Theme.of(context).primaryColor
+ : iconColor,
+ onPressed: () {
+ if (playback.currentTrack == null ||
+ playback.currentPlaylist == null) {
+ return;
+ }
+ try {
+ if (!_shuffled.value) {
+ playback.currentPlaylist!.shuffle();
+ _shuffled.value = true;
+ } else {
+ playback.currentPlaylist!.unshuffle();
+ _shuffled.value = false;
+ }
+ } catch (e, stack) {
+ print("[PlayerControls.onShuffle()] $e");
+ print(stack);
+ }
+ }),
+ IconButton(
+ icon: const Icon(Icons.skip_previous_rounded),
+ color: iconColor,
+ onPressed: () {
+ onPrevious();
+ }),
+ IconButton(
+ icon: Icon(
+ playback.isPlaying
+ ? Icons.pause_rounded
+ : Icons.play_arrow_rounded,
+ ),
+ color: iconColor,
+ onPressed: _playOrPause,
),
- onPressed: () => _playOrPause(null),
- ),
- IconButton(
+ IconButton(
icon: const Icon(Icons.skip_next_rounded),
- onPressed: () => widget.onNext?.call()),
- IconButton(
- icon: const Icon(Icons.stop_rounded),
- onPressed: () => widget.onStop?.call(),
- )
- ],
- )
- ],
- ),
- );
+ onPressed: () => onNext(),
+ color: iconColor,
+ ),
+ IconButton(
+ icon: const Icon(Icons.stop_rounded),
+ color: iconColor,
+ onPressed: playback.currentTrack != null
+ ? () async {
+ try {
+ await player.pause();
+ await player.seek(Duration.zero);
+ _shuffled.value = false;
+ playback.reset();
+ } catch (e, stack) {
+ print("[PlayerControls.onStop()] $e");
+ print(stack);
+ }
+ }
+ : null,
+ )
+ ],
+ ),
+ ],
+ ));
}
}
diff --git a/lib/components/Player/PlayerOverlay.dart b/lib/components/Player/PlayerOverlay.dart
new file mode 100644
index 00000000..b3140fb7
--- /dev/null
+++ b/lib/components/Player/PlayerOverlay.dart
@@ -0,0 +1,94 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:spotube/components/Player/PlayerTrackDetails.dart';
+import 'package:spotube/hooks/playback.dart';
+import 'package:spotube/hooks/useBreakpoints.dart';
+import 'package:spotube/hooks/useIsCurrentRoute.dart';
+import 'package:spotube/hooks/usePaletteColor.dart';
+import 'package:spotube/provider/Playback.dart';
+
+class PlayerOverlay extends HookConsumerWidget {
+ final String albumArt;
+
+ const PlayerOverlay({
+ required this.albumArt,
+ Key? key,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context, ref) {
+ final breakpoint = useBreakpoints();
+ final isCurrentRoute = useIsCurrentRoute("/");
+ final paletteColor = usePaletteColor(context, albumArt);
+ final playback = ref.watch(playbackProvider);
+
+ if (isCurrentRoute == false) {
+ return Container();
+ }
+
+ final onNext = useNextTrack(playback);
+
+ final onPrevious = usePreviousTrack(playback);
+
+ final _playOrPause = useTogglePlayPause(playback);
+
+ return Positioned(
+ right: (breakpoint.isMd ? 10 : 5),
+ left: (breakpoint.isSm ? 5 : 80),
+ bottom: (breakpoint.isSm ? 63 : 10),
+ child: Container(
+ 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: GestureDetector(
+ onTap: () => GoRouter.of(context).push(
+ "/player",
+ extra: paletteColor,
+ ),
+ child: PlayerTrackDetails(
+ albumArt: albumArt,
+ color: paletteColor.bodyTextColor,
+ ),
+ ),
+ ),
+ 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: _playOrPause,
+ ),
+ IconButton(
+ icon: const Icon(Icons.skip_next_rounded),
+ onPressed: () => onNext(),
+ color: paletteColor.bodyTextColor,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/components/Player/PlayerTrackDetails.dart b/lib/components/Player/PlayerTrackDetails.dart
new file mode 100644
index 00000000..34bd9297
--- /dev/null
+++ b/lib/components/Player/PlayerTrackDetails.dart
@@ -0,0 +1,73 @@
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:spotube/helpers/artists-to-clickable-artists.dart';
+import 'package:spotube/hooks/useBreakpoints.dart';
+import 'package:spotube/provider/Playback.dart';
+
+class PlayerTrackDetails extends HookConsumerWidget {
+ final String? albumArt;
+ final Color? color;
+ const PlayerTrackDetails({Key? key, this.albumArt, this.color})
+ : super(key: key);
+
+ @override
+ Widget build(BuildContext context, ref) {
+ final breakpoint = useBreakpoints();
+ final playback = ref.watch(playbackProvider);
+
+ return Row(
+ children: [
+ if (albumArt != null)
+ Padding(
+ padding: const EdgeInsets.all(5.0),
+ child: CachedNetworkImage(
+ imageUrl: albumArt!,
+ maxHeightDiskCache: 50,
+ maxWidthDiskCache: 50,
+ cacheKey: albumArt,
+ placeholder: (context, url) {
+ return Container(
+ height: 50,
+ width: 50,
+ color: Colors.green[400],
+ );
+ },
+ ),
+ ),
+ if (breakpoint.isLessThanOrEqualTo(Breakpoints.md))
+ Flexible(
+ child: Text(
+ playback.currentTrack?.name ?? "Not playing",
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context)
+ .textTheme
+ .bodyText1
+ ?.copyWith(fontWeight: FontWeight.bold, color: color),
+ ),
+ ),
+
+ // title of the currently playing track
+ if (breakpoint.isMoreThan(Breakpoints.md))
+ Flexible(
+ flex: 1,
+ child: Column(
+ children: [
+ Text(
+ playback.currentTrack?.name ?? "Not playing",
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context)
+ .textTheme
+ .bodyText1
+ ?.copyWith(fontWeight: FontWeight.bold, color: color),
+ ),
+ artistsToClickableArtists(
+ playback.currentTrack?.artists ?? [],
+ )
+ ],
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/components/Player/PlayerView.dart b/lib/components/Player/PlayerView.dart
new file mode 100644
index 00000000..1f910a2d
--- /dev/null
+++ b/lib/components/Player/PlayerView.dart
@@ -0,0 +1,100 @@
+import 'package:cached_network_image/cached_network_image.dart';
+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:palette_generator/palette_generator.dart';
+import 'package:spotube/components/Player/PlayerControls.dart';
+import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
+import 'package:spotube/helpers/artists-to-clickable-artists.dart';
+import 'package:spotube/helpers/image-to-url-string.dart';
+import 'package:spotube/hooks/useBreakpoints.dart';
+import 'package:spotube/provider/Playback.dart';
+
+class PlayerView extends HookConsumerWidget {
+ final PaletteColor paletteColor;
+ const PlayerView({
+ required this.paletteColor,
+ Key? key,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context, ref) {
+ final currentTrack = ref.watch(playbackProvider.select(
+ (value) => value.currentTrack,
+ ));
+ final breakpoint = useBreakpoints();
+
+ useEffect(() {
+ if (breakpoint.isMoreThan(Breakpoints.md)) {
+ WidgetsBinding.instance?.addPostFrameCallback((_) {
+ GoRouter.of(context).pop();
+ });
+ }
+ return null;
+ }, [breakpoint]);
+
+ String albumArt = useMemoized(
+ () => imageToUrlString(
+ currentTrack?.album?.images,
+ index: (currentTrack?.album?.images?.length ?? 1) - 1,
+ ),
+ [currentTrack?.album?.images],
+ );
+
+ return SafeArea(
+ child: Scaffold(
+ appBar: const PageWindowTitleBar(
+ leading: BackButton(),
+ ),
+ backgroundColor: paletteColor.color,
+ body: Column(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(10),
+ child: Column(
+ children: [
+ Text(
+ currentTrack?.name ?? "Not playing",
+ overflow: TextOverflow.ellipsis,
+ style: Theme.of(context).textTheme.headline4?.copyWith(
+ fontWeight: FontWeight.bold,
+ color: paletteColor.titleTextColor,
+ ),
+ ),
+ artistsToClickableArtists(
+ currentTrack?.artists ?? [],
+ textStyle: Theme.of(context).textTheme.headline6!.copyWith(
+ fontWeight: FontWeight.bold,
+ color: paletteColor.bodyTextColor,
+ ),
+ ),
+ ],
+ ),
+ ),
+ HookBuilder(builder: (context) {
+ final ticker = useSingleTickerProvider();
+ final controller = useAnimationController(
+ duration: const Duration(seconds: 10),
+ vsync: ticker,
+ )..repeat();
+ return RotationTransition(
+ turns: Tween(begin: 0.0, end: 1.0).animate(controller),
+ child: CircleAvatar(
+ backgroundImage: CachedNetworkImageProvider(
+ albumArt,
+ cacheKey: albumArt,
+ ),
+ radius: MediaQuery.of(context).size.width *
+ (breakpoint.isSm ? 0.4 : 0.3),
+ ),
+ );
+ }),
+ PlayerControls(iconColor: paletteColor.bodyTextColor),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/components/Playlist/PlaylistCard.dart b/lib/components/Playlist/PlaylistCard.dart
index c79052dc..95f2c60c 100644
--- a/lib/components/Playlist/PlaylistCard.dart
+++ b/lib/components/Playlist/PlaylistCard.dart
@@ -1,45 +1,46 @@
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Playlist/PlaylistView.dart';
import 'package:spotube/components/Shared/PlaybuttonCard.dart';
+import 'package:spotube/components/Shared/SpotubePageRoute.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
+import 'package:spotube/hooks/useBreakpointValue.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class PlaylistCard extends StatefulWidget {
+class PlaylistCard extends HookConsumerWidget {
final PlaylistSimple playlist;
const PlaylistCard(this.playlist, {Key? key}) : super(key: key);
@override
- _PlaylistCardState createState() => _PlaylistCardState();
-}
-
-class _PlaylistCardState extends State {
- @override
- Widget build(BuildContext context) {
- Playback playback = context.watch();
+ Widget build(BuildContext context, ref) {
+ Playback playback = ref.watch(playbackProvider);
bool isPlaylistPlaying = playback.currentPlaylist != null &&
- playback.currentPlaylist!.id == widget.playlist.id;
+ playback.currentPlaylist!.id == playlist.id;
+
+ final int marginH =
+ useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
return PlaybuttonCard(
- title: widget.playlist.name!,
- imageUrl: widget.playlist.images![0].url!,
+ margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
+ title: playlist.name!,
+ imageUrl: playlist.images![0].url!,
isPlaying: isPlaylistPlaying,
onTap: () {
- Navigator.of(context).push(MaterialPageRoute(
- builder: (context) {
- return PlaylistView(widget.playlist);
- },
- ));
+ GoRouter.of(context).push(
+ "/playlist/${playlist.id}",
+ extra: playlist,
+ );
},
onPlaybuttonPressed: () async {
if (isPlaylistPlaying) return;
- SpotifyDI data = context.read();
+ SpotifyApi spotifyApi = ref.read(spotifyProvider);
- List tracks = (widget.playlist.id != "user-liked-tracks"
- ? await data.spotifyApi.playlists
- .getTracksByPlaylistId(widget.playlist.id!)
+ List tracks = (playlist.id != "user-liked-tracks"
+ ? await spotifyApi.playlists
+ .getTracksByPlaylistId(playlist.id!)
.all()
- : await data.spotifyApi.tracks.me.saved
+ : await spotifyApi.tracks.me.saved
.all()
.then((tracks) => tracks.map((e) => e.track!)))
.toList();
@@ -48,11 +49,12 @@ class _PlaylistCardState extends State {
playback.setCurrentPlaylist = CurrentPlaylist(
tracks: tracks,
- id: widget.playlist.id!,
- name: widget.playlist.name!,
- thumbnail: imageToUrlString(widget.playlist.images),
+ id: playlist.id!,
+ name: playlist.name!,
+ thumbnail: imageToUrlString(playlist.images),
);
playback.setCurrentTrack = tracks.first;
+ await playback.startPlaying();
},
);
}
diff --git a/lib/components/Playlist/PlaylistGenreView.dart b/lib/components/Playlist/PlaylistGenreView.dart
index 44597c60..647b517e 100644
--- a/lib/components/Playlist/PlaylistGenreView.dart
+++ b/lib/components/Playlist/PlaylistGenreView.dart
@@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Playlist/PlaylistCard.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class PlaylistGenreView extends StatefulWidget {
+class PlaylistGenreView extends ConsumerWidget {
final String genreId;
final String genreName;
final Iterable? playlists;
@@ -15,13 +15,9 @@ class PlaylistGenreView extends StatefulWidget {
this.playlists,
Key? key,
}) : super(key: key);
- @override
- _PlaylistGenreViewState createState() => _PlaylistGenreViewState();
-}
-class _PlaylistGenreViewState extends State {
@override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, ref) {
return Scaffold(
appBar: const PageWindowTitleBar(
leading: BackButton(),
@@ -29,43 +25,46 @@ class _PlaylistGenreViewState extends State {
body: Column(
children: [
Text(
- widget.genreName,
+ genreName,
style: Theme.of(context).textTheme.headline4,
textAlign: TextAlign.center,
),
- Consumer(
- builder: (context, data, child) => Expanded(
- child: SingleChildScrollView(
- child: FutureBuilder>(
- future: widget.playlists == null
- ? (widget.genreId != "user-featured-playlists"
- ? data.spotifyApi.playlists
- .getByCategoryId(widget.genreId)
- .all()
- : data.spotifyApi.playlists.featured.all())
- : Future.value(widget.playlists),
- builder: (context, snapshot) {
- if (snapshot.hasError) {
- return const Center(child: Text("Error occurred"));
- }
- if (!snapshot.hasData) {
- return const CircularProgressIndicator.adaptive();
- }
- return Center(
- child: Wrap(
- children: snapshot.data!
- .map(
- (playlist) => Padding(
- padding: const EdgeInsets.all(8.0),
- child: PlaylistCard(playlist),
- ),
- )
- .toList(),
- ),
- );
- }),
- ),
- ),
+ Consumer(
+ builder: (context, ref, child) {
+ SpotifyApi spotifyApi = ref.watch(spotifyProvider);
+ return Expanded(
+ child: SingleChildScrollView(
+ child: FutureBuilder>(
+ future: playlists == null
+ ? (genreId != "user-featured-playlists"
+ ? spotifyApi.playlists
+ .getByCategoryId(genreId)
+ .all()
+ : spotifyApi.playlists.featured.all())
+ : Future.value(playlists),
+ builder: (context, snapshot) {
+ if (snapshot.hasError) {
+ return const Center(child: Text("Error occurred"));
+ }
+ if (!snapshot.hasData) {
+ return const CircularProgressIndicator.adaptive();
+ }
+ return Center(
+ child: Wrap(
+ children: snapshot.data!
+ .map(
+ (playlist) => Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: PlaylistCard(playlist),
+ ),
+ )
+ .toList(),
+ ),
+ );
+ }),
+ ),
+ );
+ },
)
],
),
diff --git a/lib/components/Playlist/PlaylistView.dart b/lib/components/Playlist/PlaylistView.dart
index 524a251f..fefced39 100644
--- a/lib/components/Playlist/PlaylistView.dart
+++ b/lib/components/Playlist/PlaylistView.dart
@@ -1,30 +1,27 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
import 'package:spotube/components/Shared/TracksTableView.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class PlaylistView extends StatefulWidget {
+class PlaylistView extends ConsumerWidget {
final PlaylistSimple playlist;
const PlaylistView(this.playlist, {Key? key}) : super(key: key);
- @override
- _PlaylistViewState createState() => _PlaylistViewState();
-}
-class _PlaylistViewState extends State {
- playPlaylist(Playback playback, List tracks, {Track? currentTrack}) {
+ playPlaylist(Playback playback, List tracks,
+ {Track? currentTrack}) async {
currentTrack ??= tracks.first;
var isPlaylistPlaying = playback.currentPlaylist?.id != null &&
- playback.currentPlaylist?.id == widget.playlist.id;
+ playback.currentPlaylist?.id == playlist.id;
if (!isPlaylistPlaying) {
playback.setCurrentPlaylist = CurrentPlaylist(
tracks: tracks,
- id: widget.playlist.id!,
- name: widget.playlist.name!,
- thumbnail: imageToUrlString(widget.playlist.images),
+ id: playlist.id!,
+ name: playlist.name!,
+ thumbnail: imageToUrlString(playlist.images),
);
playback.setCurrentTrack = currentTrack;
} else if (isPlaylistPlaying &&
@@ -32,21 +29,21 @@ class _PlaylistViewState extends State {
currentTrack.id != playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack;
}
+ await playback.startPlaying();
}
@override
- Widget build(BuildContext context) {
- Playback playback = context.watch();
+ Widget build(BuildContext context, ref) {
+ Playback playback = ref.watch(playbackProvider);
+ SpotifyApi spotifyApi = ref.watch(spotifyProvider);
var isPlaylistPlaying = playback.currentPlaylist?.id != null &&
- playback.currentPlaylist?.id == widget.playlist.id;
- return Consumer(builder: (_, data, __) {
- return Scaffold(
+ playback.currentPlaylist?.id == playlist.id;
+ return SafeArea(
+ child: Scaffold(
body: FutureBuilder>(
- future: widget.playlist.id != "user-liked-tracks"
- ? data.spotifyApi.playlists
- .getTracksByPlaylistId(widget.playlist.id)
- .all()
- : data.spotifyApi.tracks.me.saved
+ future: playlist.id != "user-liked-tracks"
+ ? spotifyApi.playlists.getTracksByPlaylistId(playlist.id).all()
+ : spotifyApi.tracks.me.saved
.all()
.then((tracks) => tracks.map((e) => e.track!)),
builder: (context, snapshot) {
@@ -78,7 +75,7 @@ class _PlaylistViewState extends State {
),
),
Center(
- child: Text(widget.playlist.name!,
+ child: Text(playlist.name!,
style: Theme.of(context).textTheme.headline4),
),
snapshot.hasError
@@ -100,7 +97,7 @@ class _PlaylistViewState extends State {
],
);
}),
- );
- });
+ ),
+ );
}
}
diff --git a/lib/components/Search/Search.dart b/lib/components/Search/Search.dart
index 10649e1b..6dd2e493 100644
--- a/lib/components/Search/Search.dart
+++ b/lib/components/Search/Search.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart' hide Page;
-import 'package:provider/provider.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+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';
@@ -11,27 +12,14 @@ import 'package:spotube/helpers/zero-pad-num-str.dart';
import 'package:spotube/provider/Playback.dart';
import 'package:spotube/provider/SpotifyDI.dart';
-class Search extends StatefulWidget {
+class Search extends HookConsumerWidget {
const Search({Key? key}) : super(key: key);
@override
- State createState() => _SearchState();
-}
-
-class _SearchState extends State {
- late TextEditingController _controller;
-
- String searchTerm = "";
-
- @override
- void initState() {
- super.initState();
- _controller = TextEditingController();
- }
-
- @override
- Widget build(BuildContext context) {
- SpotifyApi spotify = context.watch().spotifyApi;
+ Widget build(BuildContext context, ref) {
+ SpotifyApi spotify = ref.watch(spotifyProvider);
+ var controller = useTextEditingController();
+ var searchTerm = useState("");
return Expanded(
child: Column(
@@ -43,11 +31,9 @@ class _SearchState extends State {
Expanded(
child: TextField(
decoration: const InputDecoration(hintText: "Search..."),
- controller: _controller,
+ controller: controller,
onSubmitted: (value) {
- setState(() {
- searchTerm = _controller.value.text;
- });
+ searchTerm.value = controller.value.text;
},
),
),
@@ -60,27 +46,25 @@ class _SearchState extends State {
textColor: Colors.white,
child: const Icon(Icons.search_rounded),
onPressed: () {
- setState(() {
- searchTerm = _controller.value.text;
- });
+ searchTerm.value = controller.value.text;
},
),
],
),
),
FutureBuilder>(
- future: searchTerm.isNotEmpty
- ? spotify.search.get(searchTerm).first(10)
+ future: searchTerm.value.isNotEmpty
+ ? spotify.search.get(searchTerm.value).first(10)
: null,
builder: (context, snapshot) {
- if (!snapshot.hasData && searchTerm.isNotEmpty) {
+ if (!snapshot.hasData && searchTerm.value.isNotEmpty) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
- } else if (!snapshot.hasData && searchTerm.isEmpty) {
+ } else if (!snapshot.hasData && searchTerm.value.isEmpty) {
return Container();
}
- Playback playback = context.watch();
+ Playback playback = ref.watch(playbackProvider);
List albums = [];
List artists = [];
List tracks = [];
@@ -115,8 +99,7 @@ class _SearchState extends State {
...tracks.asMap().entries.map((track) {
String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
- return TracksTableView.buildTrackTile(
- context,
+ return TrackTile(
playback,
track: track,
duration: duration,
@@ -142,6 +125,7 @@ class _SearchState extends State {
playback.currentTrack?.id) {
playback.setCurrentTrack = currentTrack;
}
+ await playback.startPlaying();
},
);
}),
diff --git a/lib/components/Settings.dart b/lib/components/Settings.dart
index 705e2059..48d4335b 100644
--- a/lib/components/Settings.dart
+++ b/lib/components/Settings.dart
@@ -1,213 +1,202 @@
+import 'dart:io';
+
import 'package:flutter/material.dart';
-import 'package:hotkey_manager/hotkey_manager.dart';
-import 'package:provider/provider.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/SettingsHotkeyTile.dart';
import 'package:spotube/components/Shared/Hyperlink.dart';
import 'package:spotube/components/Shared/PageWindowTitleBar.dart';
-import 'package:spotube/main.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart';
+import 'package:spotube/provider/ThemeProvider.dart';
import 'package:spotube/provider/UserPreferences.dart';
-class Settings extends StatefulWidget {
+class Settings extends HookConsumerWidget {
const Settings({Key? key}) : super(key: key);
@override
- _SettingsState createState() => _SettingsState();
-}
+ Widget build(BuildContext context, ref) {
+ UserPreferences preferences = ref.watch(userPreferencesProvider);
+ ThemeMode theme = ref.watch(themeProvider);
+ var geniusAccessToken = useState(null);
+ TextEditingController textEditingController = useTextEditingController();
-class _SettingsState extends State {
- TextEditingController? _textEditingController;
- String? _geniusAccessToken;
-
- @override
- void initState() {
- super.initState();
- _textEditingController = TextEditingController();
- _textEditingController?.addListener(() {
- setState(() {
- _geniusAccessToken = _textEditingController?.value.text;
- });
+ textEditingController.addListener(() {
+ geniusAccessToken.value = textEditingController.value.text;
});
- }
- @override
- void dispose() {
- _textEditingController?.dispose();
- super.dispose();
- }
-
- @override
- Widget build(BuildContext context) {
- UserPreferences preferences = context.watch();
-
- return Scaffold(
- appBar: PageWindowTitleBar(
- leading: const BackButton(),
- center: Text(
- "Settings",
- style: Theme.of(context).textTheme.headline5,
+ return SafeArea(
+ child: Scaffold(
+ appBar: PageWindowTitleBar(
+ leading: const BackButton(),
+ center: Text(
+ "Settings",
+ style: Theme.of(context).textTheme.headline5,
+ ),
),
- ),
- body: Padding(
- padding: const EdgeInsets.all(16.0),
- child: Column(
- children: [
- Row(
- children: [
- Expanded(
- flex: 2,
- child: Text(
- "Genius Access Token",
- style: Theme.of(context).textTheme.subtitle1,
- ),
- ),
- Expanded(
- flex: 1,
- child: TextField(
- controller: _textEditingController,
- decoration: InputDecoration(
- hintText: preferences.geniusAccessToken,
+ body: Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ Expanded(
+ flex: 2,
+ child: Text(
+ "Genius Access Token",
+ style: Theme.of(context).textTheme.subtitle1,
),
),
- ),
- Padding(
- padding: const EdgeInsets.all(8.0),
- child: ElevatedButton(
- onPressed: _geniusAccessToken != null
- ? () async {
- SharedPreferences localStorage =
- await SharedPreferences.getInstance();
- preferences
- .setGeniusAccessToken(_geniusAccessToken);
- localStorage.setString(
- LocalStorageKeys.geniusAccessToken,
- _geniusAccessToken!);
- setState(() {
- _geniusAccessToken = null;
- });
- _textEditingController?.text = "";
- }
- : null,
- child: const Text("Save"),
- ),
- )
- ],
- ),
- const SizedBox(height: 10),
- SettingsHotKeyTile(
- title: "Next track global shortcut",
- currentHotKey: preferences.nextTrackHotKey,
- onHotKeyRecorded: (value) {
- preferences.setNextTrackHotKey(value);
- },
- ),
- SettingsHotKeyTile(
- title: "Prev track global shortcut",
- currentHotKey: preferences.prevTrackHotKey,
- onHotKeyRecorded: (value) {
- preferences.setPrevTrackHotKey(value);
- },
- ),
- SettingsHotKeyTile(
- title: "Play/Pause global shortcut",
- currentHotKey: preferences.playPauseHotKey,
- onHotKeyRecorded: (value) {
- preferences.setPlayPauseHotKey(value);
- },
- ),
- Row(
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: [
- const Text("Theme"),
- DropdownButton(
- value: MyApp.of(context)?.getThemeMode(),
- items: const [
- DropdownMenuItem(
- child: Text(
- "Dark",
+ Expanded(
+ flex: 1,
+ child: TextField(
+ controller: textEditingController,
+ decoration: InputDecoration(
+ hintText: preferences.geniusAccessToken,
),
- value: ThemeMode.dark,
),
- DropdownMenuItem(
- child: Text(
- "Light",
- ),
- value: ThemeMode.light,
+ ),
+ Padding(
+ padding: const EdgeInsets.all(8.0),
+ child: ElevatedButton(
+ onPressed: geniusAccessToken.value != null
+ ? () async {
+ SharedPreferences localStorage =
+ await SharedPreferences.getInstance();
+ preferences.setGeniusAccessToken(
+ geniusAccessToken.value);
+ localStorage.setString(
+ LocalStorageKeys.geniusAccessToken,
+ geniusAccessToken.value ?? "");
+
+ geniusAccessToken.value = null;
+
+ textEditingController.text = "";
+ }
+ : null,
+ child: const Text("Save"),
),
- DropdownMenuItem(
- child: Text("System"),
- value: ThemeMode.system,
- ),
- ],
- onChanged: (value) {
- if (value != null) {
- MyApp.of(context)?.setThemeMode(value);
- }
+ )
+ ],
+ ),
+ const SizedBox(height: 10),
+ if (!Platform.isAndroid && !Platform.isIOS) ...[
+ SettingsHotKeyTile(
+ title: "Next track global shortcut",
+ currentHotKey: preferences.nextTrackHotKey,
+ onHotKeyRecorded: (value) {
+ preferences.setNextTrackHotKey(value);
},
- )
+ ),
+ SettingsHotKeyTile(
+ title: "Prev track global shortcut",
+ currentHotKey: preferences.prevTrackHotKey,
+ onHotKeyRecorded: (value) {
+ preferences.setPrevTrackHotKey(value);
+ },
+ ),
+ SettingsHotKeyTile(
+ title: "Play/Pause global shortcut",
+ currentHotKey: preferences.playPauseHotKey,
+ onHotKeyRecorded: (value) {
+ preferences.setPlayPauseHotKey(value);
+ },
+ ),
],
- ),
- const SizedBox(height: 10),
- Builder(builder: (context) {
- var auth = context.read();
- return Row(
+ Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- const Text("Log out of this account"),
- ElevatedButton(
- child: const Text("Logout"),
- style: ButtonStyle(
- backgroundColor: MaterialStateProperty.all(Colors.red),
- ),
- onPressed: () async {
- SharedPreferences localStorage =
- await SharedPreferences.getInstance();
- await localStorage.clear();
- auth.logout();
- Navigator.of(context).pop();
+ const Text("Theme"),
+ DropdownButton(
+ value: theme,
+ items: const [
+ DropdownMenuItem(
+ child: Text(
+ "Dark",
+ ),
+ value: ThemeMode.dark,
+ ),
+ DropdownMenuItem(
+ child: Text(
+ "Light",
+ ),
+ value: ThemeMode.light,
+ ),
+ DropdownMenuItem(
+ child: Text("System"),
+ value: ThemeMode.system,
+ ),
+ ],
+ onChanged: (value) {
+ if (value != null) {
+ ref.read(themeProvider.notifier).state = value;
+ }
},
+ )
+ ],
+ ),
+ const SizedBox(height: 10),
+ Builder(builder: (context) {
+ Auth auth = ref.watch(authProvider);
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ const Text("Log out of this account"),
+ ElevatedButton(
+ child: const Text("Logout"),
+ style: ButtonStyle(
+ backgroundColor: MaterialStateProperty.all(Colors.red),
+ ),
+ onPressed: () async {
+ SharedPreferences localStorage =
+ await SharedPreferences.getInstance();
+ await localStorage.clear();
+ auth.logout();
+ GoRouter.of(context).pop();
+ },
+ ),
+ ],
+ );
+ }),
+ const SizedBox(height: 40),
+ const Text("Spotube v1.2.0"),
+ const SizedBox(height: 10),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: const [
+ Text("Author: "),
+ Hyperlink(
+ "Kingkor Roy Tirtho",
+ "https://github.com/KRTirtho",
),
],
- );
- }),
- const SizedBox(height: 40),
- const Text("Spotube v1.2.0"),
- const SizedBox(height: 10),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: const [
- Text("Author: "),
- Hyperlink(
- "Kingkor Roy Tirtho",
- "https://github.com/KRTirtho",
- ),
- ],
- ),
- const SizedBox(height: 20),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: const [
- Hyperlink(
- "💚 Sponsor/Donate 💚",
- "https://opencollective.com/spotube",
- ),
- Text(" • "),
- Hyperlink(
- "BSD-4-Clause LICENSE",
- "https://github.com/KRTirtho/spotube/blob/master/LICENSE",
- ),
- Text(" • "),
- Hyperlink(
- "Bug Report",
- "https://github.com/KRTirtho/spotube/issues/new?assignees=&labels=bug&template=bug_report.md&title=",
- ),
- ],
- ),
- const SizedBox(height: 10),
- const Text("© Spotube 2022. All rights reserved")
- ],
+ ),
+ const SizedBox(height: 20),
+ Wrap(
+ alignment: WrapAlignment.center,
+ children: const [
+ Hyperlink(
+ "💚 Sponsor/Donate 💚",
+ "https://opencollective.com/spotube",
+ ),
+ Text(" • "),
+ Hyperlink(
+ "BSD-4-Clause LICENSE",
+ "https://github.com/KRTirtho/spotube/blob/master/LICENSE",
+ ),
+ Text(" • "),
+ Hyperlink(
+ "Bug Report",
+ "https://github.com/KRTirtho/spotube/issues/new?assignees=&labels=bug&template=bug_report.md&title=",
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ const Text("© Spotube 2022. All rights reserved")
+ ],
+ ),
),
),
);
diff --git a/lib/components/Shared/AnchorButton.dart b/lib/components/Shared/AnchorButton.dart
index c02d82fa..5af2b20b 100644
--- a/lib/components/Shared/AnchorButton.dart
+++ b/lib/components/Shared/AnchorButton.dart
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
-class AnchorButton extends StatefulWidget {
+class AnchorButton extends HookWidget {
final String text;
final TextStyle style;
final TextAlign? textAlign;
@@ -16,33 +17,29 @@ class AnchorButton extends StatefulWidget {
this.style = const TextStyle(),
}) : super(key: key);
- @override
- State> createState() => _AnchorButtonState();
-}
-
-class _AnchorButtonState extends State> {
- bool _hover = false;
- bool _tap = false;
-
@override
Widget build(BuildContext context) {
+ var hover = useState(false);
+ var tap = useState(false);
+
return GestureDetector(
child: MouseRegion(
cursor: MaterialStateMouseCursor.clickable,
child: Text(
- widget.text,
- style: widget.style.copyWith(
- decoration: _hover || _tap ? TextDecoration.underline : null,
+ text,
+ style: style.copyWith(
+ decoration:
+ hover.value || tap.value ? TextDecoration.underline : null,
),
- textAlign: widget.textAlign,
- overflow: widget.overflow,
+ textAlign: textAlign,
+ overflow: overflow,
),
- onEnter: (event) => setState(() => _hover = true),
- onExit: (event) => setState(() => _hover = false),
+ onEnter: (event) => hover.value = true,
+ onExit: (event) => hover.value = false,
),
- onTapDown: (event) => setState(() => _tap = true),
- onTapUp: (event) => setState(() => _tap = false),
- onTap: widget.onTap,
+ onTapDown: (event) => tap.value = true,
+ onTapUp: (event) => tap.value = false,
+ onTap: onTap,
);
}
}
diff --git a/lib/components/Shared/DownloadTrackButton.dart b/lib/components/Shared/DownloadTrackButton.dart
index 3d8d9fcc..5be588d8 100644
--- a/lib/components/Shared/DownloadTrackButton.dart
+++ b/lib/components/Shared/DownloadTrackButton.dart
@@ -1,106 +1,86 @@
import 'dart:io';
import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/helpers/artist-to-string.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
import 'package:path_provider/path_provider.dart' as path_provider;
import 'package:path/path.dart' as path;
-class DownloadTrackButton extends StatefulWidget {
+enum TrackStatus { downloading, idle, done }
+
+class DownloadTrackButton extends HookWidget {
final Track? track;
const DownloadTrackButton({Key? key, this.track}) : super(key: key);
@override
- _DownloadTrackButtonState createState() => _DownloadTrackButtonState();
-}
+ Widget build(BuildContext context) {
+ var status = useState(TrackStatus.idle);
+ YoutubeExplode yt = useMemoized(() => YoutubeExplode());
-enum TrackStatus { downloading, idle, done }
+ var _downloadTrack = useCallback(() async {
+ if (track == null) return;
+ StreamManifest manifest =
+ await yt.videos.streamsClient.getManifest(track?.href);
-class _DownloadTrackButtonState extends State {
- late YoutubeExplode yt;
- TrackStatus status = TrackStatus.idle;
+ var audioStream = yt.videos.streamsClient.get(
+ manifest.audioOnly
+ .where((audio) => audio.codec.mimeType == "audio/mp4")
+ .withHighestBitrate(),
+ );
- @override
- void initState() {
- yt = YoutubeExplode();
- super.initState();
- }
-
- @override
- void dispose() {
- yt.close();
- super.dispose();
- }
-
- _downloadTrack() async {
- if (widget.track == null) return;
- StreamManifest manifest =
- await yt.videos.streamsClient.getManifest(widget.track?.href);
-
- var audioStream = yt.videos.streamsClient
- .get(manifest.audioOnly.withHighestBitrate())
- .asBroadcastStream();
-
- var statusCb = audioStream.listen(
- (event) {
- if (status != TrackStatus.downloading) {
- setState(() {
- status = TrackStatus.downloading;
- });
- }
- },
- onDone: () async {
- setState(() {
- status = TrackStatus.done;
- });
- await Future.delayed(
- const Duration(seconds: 3),
- () {
- if (status == TrackStatus.done) {
- setState(() {
- status = TrackStatus.idle;
- });
- }
- },
- );
- },
- );
-
- String downloadFolder = path.join(
- (await path_provider.getDownloadsDirectory())!.path, "Spotube");
- String fileName =
- "${widget.track?.name} - ${artistsToString(widget.track?.artists ?? [])}.mp3";
- File outputFile = File(path.join(downloadFolder, fileName));
- if (!outputFile.existsSync()) {
- outputFile.createSync(recursive: true);
- IOSink outputFileStream = outputFile.openWrite();
- await audioStream.pipe(outputFileStream);
- await outputFileStream.flush();
- await outputFileStream.close().then((value) async {
- if (status == TrackStatus.downloading) {
- setState(() {
- status = TrackStatus.done;
- });
+ var statusCb = audioStream.listen(
+ (event) {
+ if (status.value != TrackStatus.downloading) {
+ status.value = TrackStatus.downloading;
+ }
+ },
+ onDone: () async {
+ status.value = TrackStatus.done;
await Future.delayed(
const Duration(seconds: 3),
() {
- if (status == TrackStatus.done) {
- setState(() {
- status = TrackStatus.idle;
- });
+ if (status.value == TrackStatus.done) {
+ status.value = TrackStatus.idle;
}
},
);
- }
- return statusCb.cancel();
- });
- }
- }
+ },
+ );
- @override
- Widget build(BuildContext context) {
- if (status == TrackStatus.downloading) {
+ String downloadFolder = path.join(
+ (await path_provider.getDownloadsDirectory())!.path, "Spotube");
+ String fileName =
+ "${track?.name} - ${artistsToString(track?.artists ?? [])}.mp3";
+ File outputFile = File(path.join(downloadFolder, fileName));
+ if (!outputFile.existsSync()) {
+ outputFile.createSync(recursive: true);
+ IOSink outputFileStream = outputFile.openWrite();
+ await audioStream.pipe(outputFileStream);
+ await outputFileStream.flush();
+ await outputFileStream.close().then((value) async {
+ if (status.value == TrackStatus.downloading) {
+ status.value = TrackStatus.done;
+ await Future.delayed(
+ const Duration(seconds: 3),
+ () {
+ if (status.value == TrackStatus.done) {
+ status.value = TrackStatus.idle;
+ }
+ },
+ );
+ }
+ return statusCb.cancel();
+ });
+ }
+ }, [track, status, yt]);
+
+ useEffect(() {
+ return () => yt.close();
+ }, []);
+
+ if (status.value == TrackStatus.downloading) {
return const SizedBox(
child: CircularProgressIndicator.adaptive(
strokeWidth: 2,
@@ -108,13 +88,13 @@ class _DownloadTrackButtonState extends State {
height: 20,
width: 20,
);
- } else if (status == TrackStatus.done) {
+ } else if (status.value == TrackStatus.done) {
return const Icon(Icons.download_done_rounded);
}
return IconButton(
icon: const Icon(Icons.download_rounded),
- onPressed: widget.track != null &&
- !(widget.track!.href ?? "").startsWith("https://api.spotify.com")
+ onPressed: track != null &&
+ !(track!.href ?? "").startsWith("https://api.spotify.com")
? _downloadTrack
: null,
);
diff --git a/lib/components/Shared/LinkText.dart b/lib/components/Shared/LinkText.dart
index 9d5d63e3..d24aaf75 100644
--- a/lib/components/Shared/LinkText.dart
+++ b/lib/components/Shared/LinkText.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
import 'package:spotube/components/Shared/AnchorButton.dart';
class LinkText extends StatelessWidget {
@@ -6,12 +7,14 @@ class LinkText extends StatelessWidget {
final TextStyle style;
final TextAlign? textAlign;
final TextOverflow? overflow;
- final Route route;
+ final String route;
+ final T? extra;
const LinkText(
this.text,
this.route, {
Key? key,
this.textAlign,
+ this.extra,
this.overflow,
this.style = const TextStyle(),
}) : super(key: key);
@@ -20,8 +23,8 @@ class LinkText extends StatelessWidget {
Widget build(BuildContext context) {
return AnchorButton(
text,
- onTap: () async {
- await Navigator.of(context).push(route);
+ onTap: () {
+ GoRouter.of(context).push(route, extra: extra);
},
key: key,
overflow: overflow,
diff --git a/lib/components/Shared/PageWindowTitleBar.dart b/lib/components/Shared/PageWindowTitleBar.dart
index 8ee4a42d..c3a0038d 100644
--- a/lib/components/Shared/PageWindowTitleBar.dart
+++ b/lib/components/Shared/PageWindowTitleBar.dart
@@ -52,10 +52,23 @@ class PageWindowTitleBar extends StatelessWidget
const PageWindowTitleBar({Key? key, this.leading, this.center})
: super(key: key);
@override
- Size get preferredSize => Size.fromHeight(appWindow.titleBarHeight);
+ Size get preferredSize => Size.fromHeight(
+ !Platform.isIOS && !Platform.isAndroid ? appWindow.titleBarHeight : 35,
+ );
@override
Widget build(BuildContext context) {
+ if (Platform.isIOS || Platform.isAndroid) {
+ return PreferredSize(
+ preferredSize: const Size.fromHeight(300),
+ child: Row(
+ children: [
+ if (leading != null) leading!,
+ Expanded(child: Center(child: center)),
+ ],
+ ),
+ );
+ }
return WindowTitleBarBox(
child: Row(
children: [
@@ -65,7 +78,8 @@ class PageWindowTitleBar extends StatelessWidget
),
if (leading != null) leading!,
Expanded(child: MoveWindow(child: Center(child: center))),
- if (!Platform.isMacOS) const TitleBarActionButtons()
+ if (!Platform.isMacOS && !Platform.isIOS && !Platform.isAndroid)
+ const TitleBarActionButtons()
],
),
);
diff --git a/lib/components/Shared/PlaybuttonCard.dart b/lib/components/Shared/PlaybuttonCard.dart
index 42b350e0..7af2f678 100644
--- a/lib/components/Shared/PlaybuttonCard.dart
+++ b/lib/components/Shared/PlaybuttonCard.dart
@@ -5,6 +5,7 @@ class PlaybuttonCard extends StatelessWidget {
final void Function()? onTap;
final void Function()? onPlaybuttonPressed;
final String? description;
+ final EdgeInsetsGeometry? margin;
final String imageUrl;
final bool isPlaying;
final String title;
@@ -12,6 +13,7 @@ class PlaybuttonCard extends StatelessWidget {
required this.imageUrl,
required this.isPlaying,
required this.title,
+ this.margin,
this.description,
this.onPlaybuttonPressed,
this.onTap,
@@ -20,88 +22,92 @@ class PlaybuttonCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return InkWell(
- onTap: onTap,
- child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 200),
- child: Ink(
- decoration: BoxDecoration(
- color: Theme.of(context).backgroundColor,
- borderRadius: BorderRadius.circular(8),
- boxShadow: [
- BoxShadow(
- blurRadius: 10,
- offset: const Offset(0, 3),
- spreadRadius: 5,
- color: Theme.of(context).shadowColor)
- ],
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- // thumbnail of the playlist
- Stack(
- children: [
- ClipRRect(
- borderRadius: BorderRadius.circular(8),
- child: CachedNetworkImage(
- imageUrl: imageUrl,
- progressIndicatorBuilder: (context, url, progress) {
- return CircularProgressIndicator.adaptive(
- value: progress.progress,
- );
- },
- ),
- ),
- Positioned.directional(
- textDirection: TextDirection.ltr,
- bottom: 10,
- end: 5,
- child: Builder(builder: (context) {
- return ElevatedButton(
- onPressed: onPlaybuttonPressed,
- child: Icon(
- isPlaying
- ? Icons.pause_rounded
- : Icons.play_arrow_rounded,
- ),
- style: ButtonStyle(
- shape: MaterialStateProperty.all(
- const CircleBorder(),
- ),
- padding: MaterialStateProperty.all(
- const EdgeInsets.all(16),
- ),
- ),
- );
- }),
- )
- ],
- ),
- const SizedBox(height: 5),
- Padding(
- padding:
- const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
- child: Column(
+ return Container(
+ margin: margin,
+ child: InkWell(
+ onTap: onTap,
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 200),
+ child: Ink(
+ decoration: BoxDecoration(
+ color: Theme.of(context).backgroundColor,
+ borderRadius: BorderRadius.circular(8),
+ boxShadow: [
+ BoxShadow(
+ blurRadius: 10,
+ offset: const Offset(0, 3),
+ spreadRadius: 5,
+ color: Theme.of(context).shadowColor)
+ ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ // thumbnail of the playlist
+ Stack(
children: [
- Text(
- title,
- style: const TextStyle(fontWeight: FontWeight.bold),
+ ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: CachedNetworkImage(
+ imageUrl: imageUrl,
+ placeholder: (context, url) =>
+ Image.asset("assets/placeholder.png"),
+ ),
),
- if (description != null) ...[
- const SizedBox(height: 10),
- Text(
- description!,
- style: TextStyle(
- fontSize: 13,
- color: Theme.of(context).textTheme.headline4?.color,
- ),
- )
- ]
+ Positioned.directional(
+ textDirection: TextDirection.ltr,
+ bottom: 10,
+ end: 5,
+ child: Builder(builder: (context) {
+ return ElevatedButton(
+ onPressed: onPlaybuttonPressed,
+ child: Icon(
+ isPlaying
+ ? Icons.pause_rounded
+ : Icons.play_arrow_rounded,
+ ),
+ style: ButtonStyle(
+ shape: MaterialStateProperty.all(
+ const CircleBorder(),
+ ),
+ padding: MaterialStateProperty.all(
+ const EdgeInsets.all(16),
+ ),
+ ),
+ );
+ }),
+ )
],
),
- )
- ],
+ const SizedBox(height: 5),
+ Padding(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
+ child: Column(
+ children: [
+ Tooltip(
+ message: title,
+ child: Text(
+ title,
+ style: const TextStyle(fontWeight: FontWeight.bold),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ if (description != null) ...[
+ const SizedBox(height: 10),
+ Text(
+ description!,
+ style: TextStyle(
+ fontSize: 13,
+ color: Theme.of(context).textTheme.headline4?.color,
+ ),
+ )
+ ]
+ ],
+ ),
+ )
+ ],
+ ),
),
),
),
diff --git a/lib/components/Shared/RecordHotKeyDialog.dart b/lib/components/Shared/RecordHotKeyDialog.dart
index c7eba2b5..40af28e2 100644
--- a/lib/components/Shared/RecordHotKeyDialog.dart
+++ b/lib/components/Shared/RecordHotKeyDialog.dart
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:go_router/go_router.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
-class RecordHotKeyDialog extends StatefulWidget {
+class RecordHotKeyDialog extends HookWidget {
final ValueChanged onHotKeyRecorded;
const RecordHotKeyDialog({
@@ -9,15 +11,9 @@ class RecordHotKeyDialog extends StatefulWidget {
required this.onHotKeyRecorded,
}) : super(key: key);
- @override
- _RecordHotKeyDialogState createState() => _RecordHotKeyDialogState();
-}
-
-class _RecordHotKeyDialogState extends State {
- HotKey _hotKey = HotKey(null);
-
@override
Widget build(BuildContext context) {
+ var _hotKey = useState(HotKey(null));
return AlertDialog(
content: SingleChildScrollView(
child: ListBody(
@@ -58,9 +54,7 @@ class _RecordHotKeyDialogState extends State {
children: [
HotKeyRecorder(
onHotKeyRecorded: (hotKey) {
- setState(() {
- _hotKey = hotKey;
- });
+ _hotKey.value = hotKey;
},
),
],
@@ -73,16 +67,16 @@ class _RecordHotKeyDialogState extends State {
TextButton(
child: const Text('Cancel'),
onPressed: () {
- Navigator.of(context).pop();
+ GoRouter.of(context).pop();
},
),
TextButton(
child: const Text('OK'),
- onPressed: !_hotKey.isSetted
+ onPressed: !_hotKey.value.isSetted
? null
: () {
- widget.onHotKeyRecorded(_hotKey);
- Navigator.of(context).pop();
+ onHotKeyRecorded(_hotKey.value);
+ GoRouter.of(context).pop();
},
),
],
diff --git a/lib/components/Shared/SpotubePageRoute.dart b/lib/components/Shared/SpotubePageRoute.dart
new file mode 100644
index 00000000..0f729873
--- /dev/null
+++ b/lib/components/Shared/SpotubePageRoute.dart
@@ -0,0 +1,36 @@
+import 'package:flutter/material.dart';
+import 'package:go_router/go_router.dart';
+
+class SpotubePageRoute extends PageRouteBuilder {
+ final Widget child;
+ SpotubePageRoute({required this.child})
+ : super(
+ pageBuilder: (context, animation, secondaryAnimation) => child,
+ settings: RouteSettings(name: child.key.toString()));
+
+ @override
+ Widget buildTransitions(BuildContext context, Animation animation,
+ Animation secondaryAnimation, Widget child) {
+ return FadeTransition(
+ opacity: animation,
+ child: child,
+ );
+ }
+}
+
+class SpotubePage extends CustomTransitionPage {
+ SpotubePage({
+ required Widget child,
+ }) : super(
+ child: child,
+ transitionsBuilder: (BuildContext context,
+ Animation animation,
+ Animation secondaryAnimation,
+ Widget child) {
+ return FadeTransition(
+ opacity: animation,
+ child: child,
+ );
+ },
+ );
+}
diff --git a/lib/components/Shared/TracksTableView.dart b/lib/components/Shared/TracksTableView.dart
index 6cacc577..dcf7f036 100644
--- a/lib/components/Shared/TracksTableView.dart
+++ b/lib/components/Shared/TracksTableView.dart
@@ -1,106 +1,31 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/Album/AlbumView.dart';
import 'package:spotube/components/Shared/LinkText.dart';
+import 'package:spotube/components/Shared/SpotubePageRoute.dart';
import 'package:spotube/helpers/artists-to-clickable-artists.dart';
import 'package:spotube/helpers/image-to-url-string.dart';
import 'package:spotube/helpers/zero-pad-num-str.dart';
+import 'package:spotube/hooks/useBreakpoints.dart';
import 'package:spotube/provider/Playback.dart';
-class TracksTableView extends StatelessWidget {
+class TracksTableView extends HookConsumerWidget {
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
final List tracks;
const TracksTableView(this.tracks, {Key? key, this.onTrackPlayButtonPressed})
: super(key: key);
- static Widget buildTrackTile(
- BuildContext context,
- Playback playback, {
- required MapEntry track,
- required String duration,
- String? thumbnailUrl,
- final void Function(Track currentTrack)? onTrackPlayButtonPressed,
- }) {
- return Row(
- children: [
- SizedBox(
- height: 20,
- width: 25,
- child: Text(
- (track.key + 1).toString(),
- textAlign: TextAlign.center,
- ),
- ),
- if (thumbnailUrl != null)
- Padding(
- padding: const EdgeInsets.all(8.0),
- child: ClipRRect(
- borderRadius: const BorderRadius.all(Radius.circular(5)),
- child: CachedNetworkImage(
- placeholder: (context, url) {
- return Container(
- height: 40,
- width: 40,
- color: Colors.green[300],
- );
- },
- imageUrl: thumbnailUrl,
- maxHeightDiskCache: 40,
- maxWidthDiskCache: 40,
- ),
- ),
- ),
- IconButton(
- icon: Icon(
- playback.currentTrack?.id != null &&
- playback.currentTrack?.id == track.value.id
- ? Icons.pause_circle_rounded
- : Icons.play_circle_rounded,
- color: Theme.of(context).primaryColor,
- ),
- onPressed: () => onTrackPlayButtonPressed?.call(
- track.value,
- ),
- ),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- track.value.name ?? "",
- style: const TextStyle(
- fontWeight: FontWeight.bold,
- fontSize: 17,
- ),
- overflow: TextOverflow.ellipsis,
- ),
- artistsToClickableArtists(track.value.artists ?? []),
- ],
- ),
- ),
- Expanded(
- child: LinkText(
- track.value.album!.name!,
- MaterialPageRoute(
- builder: (context) => AlbumView(track.value.album!),
- ),
- overflow: TextOverflow.ellipsis,
- ),
- ),
- const SizedBox(width: 10),
- Text(duration),
- const SizedBox(width: 10),
- ],
- );
- }
-
@override
- Widget build(BuildContext context) {
- Playback playback = context.watch();
+ Widget build(context, ref) {
+ Playback playback = ref.watch(playbackProvider);
TextStyle tableHeadStyle =
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
+
+ final breakpoint = useBreakpoints();
+
return Expanded(
child: Scrollbar(
child: ListView(
@@ -127,21 +52,25 @@ class TracksTableView extends StatelessWidget {
),
),
// used alignment of this table-head
- const SizedBox(width: 100),
- Expanded(
- child: Row(
- children: [
- Text(
- "Album",
- overflow: TextOverflow.ellipsis,
- style: tableHeadStyle,
- ),
- ],
- ),
- ),
- const SizedBox(width: 10),
- Text("Time", style: tableHeadStyle),
- const SizedBox(width: 10),
+ 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),
+ ]
],
),
...tracks.asMap().entries.map((track) {
@@ -151,11 +80,13 @@ class TracksTableView extends StatelessWidget {
);
String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
- return buildTrackTile(context, playback,
- track: track,
- duration: duration,
- thumbnailUrl: thumbnailUrl,
- onTrackPlayButtonPressed: onTrackPlayButtonPressed);
+ return TrackTile(
+ playback,
+ track: track,
+ duration: duration,
+ thumbnailUrl: thumbnailUrl,
+ onTrackPlayButtonPressed: onTrackPlayButtonPressed,
+ );
}).toList()
],
),
@@ -163,3 +94,103 @@ class TracksTableView extends StatelessWidget {
);
}
}
+
+class TrackTile extends HookWidget {
+ final Playback playback;
+ final MapEntry track;
+ final String duration;
+ final String? thumbnailUrl;
+ final void Function(Track currentTrack)? onTrackPlayButtonPressed;
+ const TrackTile(
+ this.playback, {
+ required this.track,
+ required this.duration,
+ this.thumbnailUrl,
+ this.onTrackPlayButtonPressed,
+ Key? key,
+ }) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ final breakpoint = useBreakpoints();
+ return Row(
+ children: [
+ SizedBox(
+ height: 20,
+ width: 25,
+ child: Text(
+ (track.key + 1).toString(),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ if (thumbnailUrl != null)
+ Padding(
+ padding: EdgeInsets.symmetric(
+ horizontal: breakpoint.isMoreThan(Breakpoints.md) ? 8.0 : 0,
+ vertical: 8.0,
+ ),
+ child: ClipRRect(
+ borderRadius: const BorderRadius.all(Radius.circular(5)),
+ child: CachedNetworkImage(
+ placeholder: (context, url) {
+ return Container(
+ height: 40,
+ width: 40,
+ color: Colors.green[300],
+ );
+ },
+ imageUrl: thumbnailUrl!,
+ maxHeightDiskCache: 40,
+ maxWidthDiskCache: 40,
+ ),
+ ),
+ ),
+ IconButton(
+ icon: Icon(
+ playback.currentTrack?.id != null &&
+ playback.currentTrack?.id == track.value.id
+ ? Icons.pause_circle_rounded
+ : Icons.play_circle_rounded,
+ color: Theme.of(context).primaryColor,
+ ),
+ onPressed: () => onTrackPlayButtonPressed?.call(
+ track.value,
+ ),
+ ),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ track.value.name ?? "",
+ style: TextStyle(
+ fontWeight: FontWeight.bold,
+ fontSize: breakpoint.isSm ? 14 : 17,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ artistsToClickableArtists(track.value.artists ?? [],
+ textStyle: TextStyle(
+ fontSize:
+ breakpoint.isLessThan(Breakpoints.lg) ? 12 : 14)),
+ ],
+ ),
+ ),
+ if (breakpoint.isMoreThan(Breakpoints.md))
+ Expanded(
+ child: LinkText(
+ track.value.album!.name!,
+ "/album/${track.value.album?.id}",
+ extra: track.value.album,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ if (!breakpoint.isSm) ...[
+ const SizedBox(width: 10),
+ Text(duration),
+ const SizedBox(width: 10)
+ ],
+ ],
+ );
+ }
+}
diff --git a/lib/helpers/artists-to-clickable-artists.dart b/lib/helpers/artists-to-clickable-artists.dart
index a4b11fa5..aa55ab02 100644
--- a/lib/helpers/artists-to-clickable-artists.dart
+++ b/lib/helpers/artists-to-clickable-artists.dart
@@ -1,16 +1,16 @@
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
-import 'package:spotube/components/Artist/ArtistProfile.dart';
import 'package:spotube/components/Shared/LinkText.dart';
Widget artistsToClickableArtists(
List artists, {
- CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
- MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
+ WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.center,
+ WrapAlignment mainAxisAlignment = WrapAlignment.center,
+ TextStyle textStyle = const TextStyle(),
}) {
- return Row(
+ return Wrap(
crossAxisAlignment: crossAxisAlignment,
- mainAxisAlignment: mainAxisAlignment,
+ alignment: mainAxisAlignment,
children: artists
.asMap()
.entries
@@ -19,10 +19,9 @@ Widget artistsToClickableArtists(
(artist.key != artists.length - 1)
? "${artist.value.name}, "
: artist.value.name!,
- MaterialPageRoute(
- builder: (context) => ArtistProfile(artist.value.id!),
- ),
+ "/artist/${artist.value.id}",
overflow: TextOverflow.ellipsis,
+ style: textStyle,
),
)
.toList(),
diff --git a/lib/helpers/image-to-url-string.dart b/lib/helpers/image-to-url-string.dart
index f9cf0938..52731ff7 100644
--- a/lib/helpers/image-to-url-string.dart
+++ b/lib/helpers/image-to-url-string.dart
@@ -1,7 +1,9 @@
import 'package:spotify/spotify.dart';
+import 'package:uuid/uuid.dart' show Uuid;
+const uuid = Uuid();
String imageToUrlString(List? images, {int index = 0}) {
return images != null && images.isNotEmpty
? images[0].url!
- : "https://avatars.dicebear.com/api/croodles-neutral/${DateTime.now().toString()}.png";
+ : "https://avatars.dicebear.com/api/bottts/${uuid.v4()}.png";
}
diff --git a/lib/helpers/oauth-login.dart b/lib/helpers/oauth-login.dart
index e9bdbeb7..32229297 100644
--- a/lib/helpers/oauth-login.dart
+++ b/lib/helpers/oauth-login.dart
@@ -1,15 +1,13 @@
-import 'package:flutter/cupertino.dart';
-import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotify/spotify.dart';
-import 'package:spotube/components/Home.dart';
+import 'package:spotube/components/Home/Home.dart';
import 'package:spotube/helpers/server_ipc.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
import 'package:spotube/provider/Auth.dart';
const redirectUri = "http://localhost:4304/auth/spotify/callback";
-Future oauthLogin(BuildContext context,
+Future oauthLogin(Auth auth,
{required String clientId, required String clientSecret}) async {
try {
String? accessToken;
@@ -50,7 +48,7 @@ Future oauthLogin(BuildContext context,
clientSecret,
);
- Provider.of(context, listen: false).setAuthState(
+ auth.setAuthState(
clientId: clientId,
clientSecret: clientSecret,
accessToken: accessToken,
diff --git a/lib/helpers/search-youtube.dart b/lib/helpers/search-youtube.dart
index 007b1dbd..cedea1e6 100644
--- a/lib/helpers/search-youtube.dart
+++ b/lib/helpers/search-youtube.dart
@@ -1,3 +1,5 @@
+import 'dart:io';
+import 'package:collection/collection.dart';
import 'package:spotify/spotify.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
@@ -21,7 +23,16 @@ Future toYoutubeTrack(YoutubeExplode youtube, Track track) async {
var trackManifest = await youtube.videos.streams.getManifest(ytVideo.id);
- track.uri = trackManifest.audioOnly.withHighestBitrate().url.toString();
+ // Since Mac OS's & IOS's CodeAudio doesn't support WebMedia
+ // ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
+ // codec/mimetype for those Platforms
+ track.uri = (Platform.isMacOS || Platform.isIOS
+ ? trackManifest.audioOnly
+ .where((info) => info.codec.mimeType == "audio/mp4")
+ .withHighestBitrate()
+ : trackManifest.audioOnly.withHighestBitrate())
+ .url
+ .toString();
track.href = ytVideo.url;
return track;
}
diff --git a/lib/hooks/playback.dart b/lib/hooks/playback.dart
new file mode 100644
index 00000000..f113d315
--- /dev/null
+++ b/lib/hooks/playback.dart
@@ -0,0 +1,41 @@
+import 'package:spotube/provider/Playback.dart';
+
+Future Function() useNextTrack(Playback playback) {
+ return () async {
+ try {
+ await playback.player.pause();
+ await playback.player.seek(Duration.zero);
+ playback.movePlaylistPositionBy(1);
+ } catch (e, stack) {
+ print("[PlayerControls.onNext()] $e");
+ print(stack);
+ }
+ };
+}
+
+Future Function() usePreviousTrack(Playback playback) {
+ return () async {
+ try {
+ await playback.player.pause();
+ await playback.player.seek(Duration.zero);
+ playback.movePlaylistPositionBy(-1);
+ } catch (e, stack) {
+ print("[PlayerControls.onPrevious()] $e");
+ print(stack);
+ }
+ };
+}
+
+Future Function([dynamic]) useTogglePlayPause(Playback playback) {
+ return ([key]) async {
+ try {
+ if (playback.currentTrack == null) return;
+ playback.isPlaying
+ ? await playback.player.pause()
+ : await playback.player.play();
+ } catch (e, stack) {
+ print("[PlayPauseShortcut] $e");
+ print(stack);
+ }
+ };
+}
diff --git a/lib/hooks/useAsyncEffect.dart b/lib/hooks/useAsyncEffect.dart
new file mode 100644
index 00000000..8af25543
--- /dev/null
+++ b/lib/hooks/useAsyncEffect.dart
@@ -0,0 +1,18 @@
+import 'dart:async';
+
+import 'package:flutter_hooks/flutter_hooks.dart';
+
+void useAsyncEffect(
+ FutureOr Function() effect, [
+ FutureOr Function()? cleanup,
+ List? keys,
+]) {
+ useEffect(() {
+ Future.microtask(effect);
+ return () {
+ if (cleanup != null) {
+ Future.microtask(cleanup);
+ }
+ };
+ }, keys);
+}
diff --git a/lib/hooks/useBreakpointValue.dart b/lib/hooks/useBreakpointValue.dart
new file mode 100644
index 00000000..58240630
--- /dev/null
+++ b/lib/hooks/useBreakpointValue.dart
@@ -0,0 +1,17 @@
+import 'package:spotube/hooks/useBreakpoints.dart';
+
+useBreakpointValue({sm, md, lg, xl, xxl}) {
+ final breakpoint = useBreakpoints();
+
+ if (breakpoint.isSm) {
+ return sm;
+ } else if (breakpoint.isMd) {
+ return md;
+ } else if (breakpoint.isXl) {
+ return xl;
+ } else if (breakpoint.isXxl) {
+ return xxl;
+ } else {
+ return lg;
+ }
+}
diff --git a/lib/hooks/useBreakpoints.dart b/lib/hooks/useBreakpoints.dart
new file mode 100644
index 00000000..e4e73334
--- /dev/null
+++ b/lib/hooks/useBreakpoints.dart
@@ -0,0 +1,104 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+
+class BreakpointUtils {
+ Breakpoints breakpoint;
+ List breakpointList = [
+ Breakpoints.sm,
+ Breakpoints.md,
+ Breakpoints.lg,
+ Breakpoints.xl,
+ Breakpoints.xxl
+ ];
+ BreakpointUtils(this.breakpoint);
+
+ bool get isSm => breakpoint == Breakpoints.sm;
+ bool get isMd => breakpoint == Breakpoints.md;
+ bool get isLg => breakpoint == Breakpoints.lg;
+ bool get isXl => breakpoint == Breakpoints.xl;
+ bool get isXxl => breakpoint == Breakpoints.xxl;
+
+ bool isMoreThanOrEqualTo(Breakpoints b) {
+ return breakpointList
+ .sublist(breakpointList.indexOf(b))
+ .contains(breakpoint);
+ }
+
+ bool isLessThanOrEqualTo(Breakpoints b) {
+ return breakpointList
+ .sublist(0, breakpointList.indexOf(b) + 1)
+ .contains(breakpoint);
+ }
+
+ bool isMoreThan(Breakpoints b) {
+ return breakpointList
+ .sublist(breakpointList.indexOf(b) + 1)
+ .contains(breakpoint);
+ }
+
+ bool isLessThan(Breakpoints b) {
+ return breakpointList
+ .sublist(0, breakpointList.indexOf(b))
+ .contains(breakpoint);
+ }
+
+ bool operator >(other) {
+ return isMoreThan(other);
+ }
+
+ bool operator <(other) {
+ return isLessThan(other);
+ }
+
+ bool operator >=(other) {
+ return isMoreThanOrEqualTo(other);
+ }
+
+ bool operator <=(other) {
+ return isLessThanOrEqualTo(other);
+ }
+
+ @override
+ String toString() {
+ return "BreakpointUtils($breakpoint)";
+ }
+}
+
+enum Breakpoints { sm, md, lg, xl, xxl }
+
+BreakpointUtils useBreakpoints() {
+ final context = useContext();
+ final width = MediaQuery.of(context).size.width;
+ final breakpoint = useState(Breakpoints.lg);
+ final utils = BreakpointUtils(breakpoint.value);
+
+ useEffect(() {
+ if (width > 1920 && breakpoint.value != Breakpoints.xxl) {
+ breakpoint.value = Breakpoints.xxl;
+ } else if (width > 1366 &&
+ width <= 1920 &&
+ breakpoint.value != Breakpoints.xl) {
+ breakpoint.value = Breakpoints.xl;
+ } else if (width > 768 &&
+ width <= 1366 &&
+ breakpoint.value != Breakpoints.lg) {
+ breakpoint.value = Breakpoints.lg;
+ } else if (width > 360 &&
+ width <= 768 &&
+ breakpoint.value != Breakpoints.md) {
+ breakpoint.value = Breakpoints.md;
+ } else if (width >= 250 &&
+ width <= 360 &&
+ breakpoint.value != Breakpoints.sm) {
+ breakpoint.value = Breakpoints.sm;
+ }
+ return null;
+ }, [width]);
+
+ useEffect(() {
+ utils.breakpoint = breakpoint.value;
+ return null;
+ }, [breakpoint.value]);
+
+ return utils;
+}
diff --git a/lib/hooks/useHotKeys.dart b/lib/hooks/useHotKeys.dart
new file mode 100644
index 00000000..94ad8f5a
--- /dev/null
+++ b/lib/hooks/useHotKeys.dart
@@ -0,0 +1,48 @@
+import 'dart:io';
+
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:hotkey_manager/hotkey_manager.dart';
+import 'package:spotube/hooks/playback.dart';
+import 'package:spotube/models/GlobalKeyActions.dart';
+import 'package:spotube/provider/Playback.dart';
+import 'package:spotube/provider/UserPreferences.dart';
+
+useHotKeys(WidgetRef ref) {
+ final playback = ref.watch(playbackProvider);
+ final preferences = ref.watch(userPreferencesProvider);
+ List _hotKeys = [];
+
+ final onNext = useNextTrack(playback);
+
+ final onPrevious = usePreviousTrack(playback);
+
+ final _playOrPause = useTogglePlayPause(playback);
+
+ useEffect(() {
+ if (Platform.isIOS || Platform.isAndroid) return null;
+ _hotKeys = [
+ GlobalKeyActions(
+ HotKey(KeyCode.space, scope: HotKeyScope.inapp),
+ _playOrPause,
+ ),
+ if (preferences.nextTrackHotKey != null)
+ GlobalKeyActions(preferences.nextTrackHotKey!, (key) => onNext()),
+ if (preferences.prevTrackHotKey != null)
+ GlobalKeyActions(preferences.prevTrackHotKey!, (key) => onPrevious()),
+ if (preferences.playPauseHotKey != null)
+ GlobalKeyActions(preferences.playPauseHotKey!, _playOrPause)
+ ];
+ Future.wait(
+ _hotKeys.map((e) {
+ return hotKeyManager.register(
+ e.hotKey,
+ keyDownHandler: e.onKeyDown,
+ );
+ }),
+ );
+ return () {
+ Future.wait(_hotKeys.map((e) => hotKeyManager.unregister(e.hotKey)));
+ };
+ });
+}
diff --git a/lib/hooks/useIsCurrentRoute.dart b/lib/hooks/useIsCurrentRoute.dart
new file mode 100644
index 00000000..eeb1ff77
--- /dev/null
+++ b/lib/hooks/useIsCurrentRoute.dart
@@ -0,0 +1,18 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:go_router/go_router.dart';
+
+bool? useIsCurrentRoute([String matcher = "/"]) {
+ final isCurrentRoute = useState(null);
+ final context = useContext();
+ useEffect(() {
+ WidgetsBinding.instance?.addPostFrameCallback((timer) {
+ final isCurrent = GoRouter.of(context).location == matcher;
+ if (isCurrent != isCurrentRoute.value) {
+ isCurrentRoute.value = isCurrent;
+ }
+ });
+ return null;
+ });
+ return isCurrentRoute.value;
+}
diff --git a/lib/hooks/usePagingController.dart b/lib/hooks/usePagingController.dart
new file mode 100644
index 00000000..a32dfa0c
--- /dev/null
+++ b/lib/hooks/usePagingController.dart
@@ -0,0 +1,53 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
+
+PagingController
+ usePagingController({
+ required final PageKeyType firstPageKey,
+ final int? invisibleItemsThreshold,
+ List? keys,
+}) {
+ return use(
+ _PagingControllerHook(
+ firstPageKey: firstPageKey,
+ invisibleItemsThreshold: invisibleItemsThreshold,
+ keys: keys,
+ ),
+ );
+}
+
+class _PagingControllerHook
+ extends Hook> {
+ const _PagingControllerHook({
+ required this.firstPageKey,
+ this.invisibleItemsThreshold,
+ List? keys,
+ }) : super(keys: keys);
+
+ final PageKeyType firstPageKey;
+ final int? invisibleItemsThreshold;
+
+ @override
+ HookState,
+ Hook>>
+ createState() => _PagingControllerHookState();
+}
+
+class _PagingControllerHookState extends HookState<
+ PagingController,
+ _PagingControllerHook> {
+ late final controller = PagingController(
+ firstPageKey: hook.firstPageKey,
+ invisibleItemsThreshold: hook.invisibleItemsThreshold);
+
+ @override
+ PagingController build(BuildContext context) =>
+ controller;
+
+ @override
+ void dispose() => controller.dispose();
+
+ @override
+ String get debugLabel => 'usePagingController';
+}
diff --git a/lib/hooks/usePaletteColor.dart b/lib/hooks/usePaletteColor.dart
new file mode 100644
index 00000000..6e19c8e1
--- /dev/null
+++ b/lib/hooks/usePaletteColor.dart
@@ -0,0 +1,33 @@
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:palette_generator/palette_generator.dart';
+
+PaletteColor usePaletteColor(BuildContext context, imageUrl) {
+ final paletteColor =
+ useState(PaletteColor(Colors.grey[300]!, 0));
+ final mounted = useIsMounted();
+
+ useEffect(() {
+ WidgetsBinding.instance?.addPostFrameCallback((timeStamp) async {
+ final palette = await PaletteGenerator.fromImageProvider(
+ CachedNetworkImageProvider(
+ imageUrl,
+ cacheKey: imageUrl,
+ maxHeight: 50,
+ maxWidth: 50,
+ ),
+ );
+ if (!mounted()) return;
+ final color = Theme.of(context).brightness == Brightness.light
+ ? palette.lightMutedColor ?? palette.lightVibrantColor
+ : palette.darkMutedColor ?? palette.darkVibrantColor;
+ if (color != null) {
+ paletteColor.value = color;
+ }
+ });
+ return null;
+ }, [imageUrl]);
+
+ return paletteColor.value;
+}
diff --git a/lib/hooks/useSharedPreferences.dart b/lib/hooks/useSharedPreferences.dart
new file mode 100644
index 00000000..922beaa6
--- /dev/null
+++ b/lib/hooks/useSharedPreferences.dart
@@ -0,0 +1,9 @@
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+SharedPreferences? useSharedPreferences() {
+ final future = useMemoized(SharedPreferences.getInstance);
+ final snapshot = useFuture(future, initialData: null);
+
+ return snapshot.data;
+}
diff --git a/lib/main.dart b/lib/main.dart
index f1bb3faa..25918053 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,210 +1,163 @@
+import 'dart:io';
+
import 'package:bitsdojo_window/bitsdojo_window.dart';
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:hotkey_manager/hotkey_manager.dart';
-import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
-import 'package:spotify/spotify.dart';
-import 'package:spotube/components/Home.dart';
+import 'package:spotube/models/GoRouteDeclarations.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
-import 'package:spotube/provider/Auth.dart';
-import 'package:spotube/provider/Playback.dart';
-import 'package:spotube/provider/SpotifyDI.dart';
-import 'package:spotube/provider/UserPreferences.dart';
+import 'package:spotube/provider/AudioPlayer.dart';
+import 'package:spotube/provider/ThemeProvider.dart';
+import 'package:spotube/provider/YouTube.dart';
void main() async {
- WidgetsFlutterBinding.ensureInitialized();
- await hotKeyManager.unregisterAll();
- runApp(MyApp());
- doWhenWindowReady(() {
- appWindow.minSize = const Size(900, 700);
- appWindow.size = const Size(900, 700);
- appWindow.alignment = Alignment.center;
- appWindow.maximize();
- appWindow.show();
- });
+ if (!Platform.isAndroid && !Platform.isIOS) {
+ WidgetsFlutterBinding.ensureInitialized();
+ await hotKeyManager.unregisterAll();
+ doWhenWindowReady(() {
+ appWindow.minSize =
+ Size(Platform.isAndroid || Platform.isIOS ? 280 : 359, 700);
+ appWindow.size = const Size(900, 700);
+ appWindow.alignment = Alignment.center;
+ appWindow.maximize();
+ appWindow.show();
+ });
+ }
+ runApp(ProviderScope(child: MyApp()));
}
-class MyApp extends StatefulWidget {
- static _MyAppState? of(BuildContext context) =>
- context.findAncestorStateOfType<_MyAppState>();
+class MyApp extends HookConsumerWidget {
+ final GoRouter _router = createGoRouter();
+
+ MyApp({Key? key}) : super(key: key);
@override
- State createState() => _MyAppState();
-}
+ Widget build(BuildContext context, ref) {
+ var themeMode = ref.watch(themeProvider);
+ var player = ref.watch(audioPlayerProvider);
+ var youtube = ref.watch(youtubeProvider);
+ useEffect(() {
+ SharedPreferences.getInstance().then((localStorage) {
+ String? themeMode = localStorage.getString(LocalStorageKeys.themeMode);
+ var themeNotifier = ref.read(themeProvider.notifier);
-class _MyAppState extends State {
- ThemeMode _themeMode = ThemeMode.system;
-
- @override
- void initState() {
- WidgetsBinding.instance?.addPostFrameCallback((timeStamp) async {
- SharedPreferences localStorage = await SharedPreferences.getInstance();
- String? themeMode = localStorage.getString(LocalStorageKeys.themeMode);
-
- setState(() {
switch (themeMode) {
case "light":
- _themeMode = ThemeMode.light;
+ themeNotifier.state = ThemeMode.light;
break;
case "dark":
- _themeMode = ThemeMode.dark;
+ themeNotifier.state = ThemeMode.dark;
break;
default:
- _themeMode = ThemeMode.system;
+ themeNotifier.state = ThemeMode.system;
}
});
- });
- super.initState();
- }
+ return () {
+ player.dispose();
+ youtube.close();
+ };
+ }, []);
- void setThemeMode(ThemeMode themeMode) {
- SharedPreferences.getInstance().then((localStorage) {
- localStorage.setString(
- LocalStorageKeys.themeMode, themeMode.toString().split(".").last);
- setState(() {
- _themeMode = themeMode;
- });
- });
- }
-
- ThemeMode getThemeMode() {
- return _themeMode;
- }
-
- @override
- Widget build(BuildContext context) {
- return MultiProvider(
- providers: [
- ChangeNotifierProvider(create: (context) => Auth()),
- ChangeNotifierProvider(create: (context) {
- Auth authState = Provider.of(context, listen: false);
- return SpotifyDI(
- SpotifyApi(
- SpotifyApiCredentials(
- authState.clientId,
- authState.clientSecret,
- accessToken: authState.accessToken,
- refreshToken: authState.refreshToken,
- expiration: authState.expiration,
- scopes: spotifyScopes,
- ),
- onCredentialsRefreshed: (credentials) async {
- SharedPreferences localStorage =
- await SharedPreferences.getInstance();
- localStorage.setString(
- LocalStorageKeys.refreshToken,
- credentials.refreshToken!,
- );
- localStorage.setString(
- LocalStorageKeys.accessToken,
- credentials.accessToken!,
- );
- localStorage.setString(
- LocalStorageKeys.clientId, credentials.clientId!);
- localStorage.setString(
- LocalStorageKeys.clientSecret,
- credentials.clientSecret!,
- );
- },
- ),
- );
- }),
- ChangeNotifierProvider(create: (context) => Playback()),
- ChangeNotifierProvider(
- create: (context) {
- return UserPreferences();
- },
+ return MaterialApp.router(
+ routeInformationParser: _router.routeInformationParser,
+ routerDelegate: _router.routerDelegate,
+ debugShowCheckedModeBanner: false,
+ title: 'Spotube',
+ theme: ThemeData(
+ primaryColor: Colors.green,
+ primarySwatch: Colors.green,
+ buttonTheme: const ButtonThemeData(
+ buttonColor: Colors.green,
),
- ],
- child: MaterialApp(
- debugShowCheckedModeBanner: false,
- title: 'Spotube',
- theme: ThemeData(
- primaryColor: Colors.green,
- primarySwatch: Colors.green,
- buttonTheme: const ButtonThemeData(
- buttonColor: Colors.green,
- ),
- shadowColor: Colors.grey[300],
- backgroundColor: Colors.white,
- textTheme: TextTheme(
- bodyText1: TextStyle(color: Colors.grey[850]),
- headline1: TextStyle(color: Colors.grey[850]),
- headline2: TextStyle(color: Colors.grey[850]),
- headline3: TextStyle(color: Colors.grey[850]),
- headline4: TextStyle(color: Colors.grey[850]),
- headline5: TextStyle(color: Colors.grey[850]),
- headline6: TextStyle(color: Colors.grey[850]),
- ),
- listTileTheme: ListTileThemeData(
- iconColor: Colors.grey[850],
- horizontalTitleGap: 0,
- ),
- inputDecorationTheme: InputDecorationTheme(
- focusedBorder: OutlineInputBorder(
- borderSide: BorderSide(
- color: Colors.green[400]!,
- width: 2.0,
- ),
- ),
- enabledBorder: OutlineInputBorder(
- borderSide: BorderSide(
- color: Colors.grey[800]!,
- ),
+ shadowColor: Colors.grey[300],
+ backgroundColor: Colors.white,
+ textTheme: TextTheme(
+ bodyText1: TextStyle(color: Colors.grey[850]),
+ headline1: TextStyle(color: Colors.grey[850]),
+ headline2: TextStyle(color: Colors.grey[850]),
+ headline3: TextStyle(color: Colors.grey[850]),
+ headline4: TextStyle(color: Colors.grey[850]),
+ headline5: TextStyle(color: Colors.grey[850]),
+ headline6: TextStyle(color: Colors.grey[850]),
+ ),
+ listTileTheme: ListTileThemeData(
+ iconColor: Colors.grey[850],
+ horizontalTitleGap: 0,
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ focusedBorder: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: Colors.green[400]!,
+ width: 2.0,
),
),
- navigationRailTheme: NavigationRailThemeData(
- backgroundColor: Colors.blueGrey[50],
- unselectedIconTheme:
- IconThemeData(color: Colors.grey[850], opacity: 1),
- unselectedLabelTextStyle: TextStyle(
- color: Colors.grey[850],
+ enabledBorder: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: Colors.grey[800]!,
),
),
- cardTheme: CardTheme(
- shape:
- RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
- color: Colors.white,
- ),
),
- darkTheme: ThemeData(
- brightness: Brightness.dark,
- primaryColor: Colors.green,
- primarySwatch: Colors.green,
- backgroundColor: Colors.blueGrey[900],
- scaffoldBackgroundColor: Colors.blueGrey[900],
- dialogBackgroundColor: Colors.blueGrey[800],
- shadowColor: Colors.black26,
- buttonTheme: const ButtonThemeData(
- buttonColor: Colors.green,
+ navigationRailTheme: NavigationRailThemeData(
+ backgroundColor: Colors.blueGrey[50],
+ unselectedIconTheme:
+ IconThemeData(color: Colors.grey[850], opacity: 1),
+ unselectedLabelTextStyle: TextStyle(
+ color: Colors.grey[850],
),
- inputDecorationTheme: InputDecorationTheme(
- focusedBorder: OutlineInputBorder(
- borderSide: BorderSide(
- color: Colors.green[400]!,
- width: 2.0,
- ),
- ),
- enabledBorder: OutlineInputBorder(
- borderSide: BorderSide(
- color: Colors.grey[800]!,
- ),
- ),
- ),
- navigationRailTheme: NavigationRailThemeData(
- backgroundColor: Colors.blueGrey[800],
- unselectedIconTheme: const IconThemeData(opacity: 1),
- ),
- cardTheme: CardTheme(
- shape:
- RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
- color: Colors.blueGrey[900],
- elevation: 20,
- ),
- canvasColor: Colors.blueGrey[900],
),
- themeMode: _themeMode,
- home: const Home(),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: Colors.blueGrey[50],
+ height: 55,
+ ),
+ cardTheme: CardTheme(
+ shape:
+ RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+ color: Colors.white,
+ ),
),
+ darkTheme: ThemeData(
+ brightness: Brightness.dark,
+ primaryColor: Colors.green,
+ primarySwatch: Colors.green,
+ backgroundColor: Colors.blueGrey[900],
+ scaffoldBackgroundColor: Colors.blueGrey[900],
+ dialogBackgroundColor: Colors.blueGrey[800],
+ shadowColor: Colors.black26,
+ buttonTheme: const ButtonThemeData(
+ buttonColor: Colors.green,
+ ),
+ inputDecorationTheme: InputDecorationTheme(
+ focusedBorder: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: Colors.green[400]!,
+ width: 2.0,
+ ),
+ ),
+ enabledBorder: OutlineInputBorder(
+ borderSide: BorderSide(
+ color: Colors.grey[800]!,
+ ),
+ ),
+ ),
+ navigationRailTheme: NavigationRailThemeData(
+ backgroundColor: Colors.blueGrey[800],
+ unselectedIconTheme: const IconThemeData(opacity: 1),
+ ),
+ navigationBarTheme: NavigationBarThemeData(
+ backgroundColor: Colors.blueGrey[800],
+ height: 55,
+ ),
+ cardTheme: CardTheme(
+ shape:
+ RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
+ color: Colors.blueGrey[900],
+ elevation: 20,
+ ),
+ canvasColor: Colors.blueGrey[900],
+ ),
+ themeMode: themeMode,
);
}
}
diff --git a/lib/models/GoRouteDeclarations.dart b/lib/models/GoRouteDeclarations.dart
new file mode 100644
index 00000000..3a871a10
--- /dev/null
+++ b/lib/models/GoRouteDeclarations.dart
@@ -0,0 +1,73 @@
+import 'package:go_router/go_router.dart';
+import 'package:palette_generator/palette_generator.dart';
+import 'package:spotify/spotify.dart';
+import 'package:spotube/components/Album/AlbumView.dart';
+import 'package:spotube/components/Artist/ArtistAlbumView.dart';
+import 'package:spotube/components/Artist/ArtistProfile.dart';
+import 'package:spotube/components/Home/Home.dart';
+import 'package:spotube/components/Player/PlayerView.dart';
+import 'package:spotube/components/Playlist/PlaylistView.dart';
+import 'package:spotube/components/Settings.dart';
+import 'package:spotube/components/Shared/SpotubePageRoute.dart';
+
+GoRouter createGoRouter() => GoRouter(
+ routes: [
+ GoRoute(
+ path: "/",
+ builder: (context, state) => const Home(),
+ ),
+ GoRoute(
+ path: "/settings",
+ pageBuilder: (context, state) => SpotubePage(
+ child: const Settings(),
+ ),
+ ),
+ GoRoute(
+ path: "/album/:id",
+ pageBuilder: (context, state) {
+ assert(state.extra is AlbumSimple);
+ return SpotubePage(child: AlbumView(state.extra as AlbumSimple));
+ },
+ ),
+ GoRoute(
+ path: "/artist/:id",
+ pageBuilder: (context, state) {
+ assert(state.params["id"] != null);
+ return SpotubePage(child: ArtistProfile(state.params["id"]!));
+ },
+ ),
+ GoRoute(
+ path: "/artist-album/:id",
+ pageBuilder: (context, state) {
+ assert(state.params["id"] != null);
+ assert(state.extra is String);
+ return SpotubePage(
+ child: ArtistAlbumView(
+ state.params["id"]!,
+ state.extra as String,
+ ),
+ );
+ },
+ ),
+ GoRoute(
+ path: "/playlist/:id",
+ pageBuilder: (context, state) {
+ assert(state.extra is PlaylistSimple);
+ return SpotubePage(
+ child: PlaylistView(state.extra as PlaylistSimple),
+ );
+ },
+ ),
+ GoRoute(
+ path: "/player",
+ pageBuilder: (context, state) {
+ assert(state.extra is PaletteColor);
+ return SpotubePage(
+ child: PlayerView(
+ paletteColor: state.extra as PaletteColor,
+ ),
+ );
+ },
+ )
+ ],
+ );
diff --git a/lib/models/LocalStorageKeys.dart b/lib/models/LocalStorageKeys.dart
index 70b5936a..1af5088c 100644
--- a/lib/models/LocalStorageKeys.dart
+++ b/lib/models/LocalStorageKeys.dart
@@ -10,4 +10,6 @@ abstract class LocalStorageKeys {
static String nextTrackHotKey = "next_track_hot_key";
static String prevTrackHotKey = "prev_track_hot_key";
static String playPauseHotKey = "play_pause_hot_key";
+
+ static String volume = "volume";
}
diff --git a/lib/provider/AudioPlayer.dart b/lib/provider/AudioPlayer.dart
new file mode 100644
index 00000000..6aff379a
--- /dev/null
+++ b/lib/provider/AudioPlayer.dart
@@ -0,0 +1,6 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:just_audio/just_audio.dart';
+
+final audioPlayerProvider = Provider((ref) {
+ return AudioPlayer();
+});
diff --git a/lib/provider/Auth.dart b/lib/provider/Auth.dart
index 5249b891..af18e84e 100644
--- a/lib/provider/Auth.dart
+++ b/lib/provider/Auth.dart
@@ -1,4 +1,5 @@
import 'package:flutter/cupertino.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
class Auth with ChangeNotifier {
String? _clientId;
@@ -51,4 +52,11 @@ class Auth with ChangeNotifier {
_isLoggedIn = false;
notifyListeners();
}
+
+ @override
+ String toString() {
+ return "Auth(clientId: $clientId, clientSecret: $clientSecret, accessToken: $accessToken, refreshToken: $refreshToken, expiration: $expiration, isLoggedIn: $isLoggedIn)";
+ }
}
+
+var authProvider = ChangeNotifierProvider((ref) => Auth());
diff --git a/lib/provider/Playback.dart b/lib/provider/Playback.dart
index fb20d153..f87a0046 100644
--- a/lib/provider/Playback.dart
+++ b/lib/provider/Playback.dart
@@ -1,5 +1,14 @@
+import 'dart:async';
+
+import 'package:audio_session/audio_session.dart';
import 'package:flutter/cupertino.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:just_audio/just_audio.dart';
import 'package:spotify/spotify.dart';
+import 'package:spotube/helpers/search-youtube.dart';
+import 'package:spotube/provider/AudioPlayer.dart';
+import 'package:spotube/provider/YouTube.dart';
+import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class CurrentPlaylist {
List? _tempTrack;
@@ -7,6 +16,7 @@ class CurrentPlaylist {
String id;
String name;
String thumbnail;
+
CurrentPlaylist({
required this.tracks,
required this.id,
@@ -36,13 +46,105 @@ class CurrentPlaylist {
class Playback extends ChangeNotifier {
CurrentPlaylist? _currentPlaylist;
Track? _currentTrack;
- Playback({CurrentPlaylist? currentPlaylist, Track? currentTrack}) {
- _currentPlaylist = currentPlaylist;
- _currentTrack = currentTrack;
+
+ // states
+ bool _isPlaying = false;
+ Duration? _duration;
+
+ // using custom listeners for duration as it changes super quickly
+ // which will cause re-renders in components that don't even need it
+ // thus only allowing to listen to change in duration through only
+ // a listener function
+ List _durationListeners = [];
+
+ // listeners
+ StreamSubscription? _playingStreamListener;
+ StreamSubscription? _durationStreamListener;
+ StreamSubscription? _processingStateStreamListener;
+ StreamSubscription? _audioInterruptionEventListener;
+
+ AudioPlayer player;
+ YoutubeExplode youtube;
+ AudioSession? _audioSession;
+ Playback({
+ required this.player,
+ required this.youtube,
+ CurrentPlaylist? currentPlaylist,
+ Track? currentTrack,
+ }) : _currentPlaylist = currentPlaylist,
+ _currentTrack = currentTrack {
+ _playingStreamListener = player.playingStream.listen(
+ (playing) {
+ _isPlaying = playing;
+ notifyListeners();
+ },
+ );
+
+ _durationStreamListener = player.durationStream.listen((duration) async {
+ if (duration != null) {
+ // Actually things doesn't work all the time as they were
+ // described. So instead of listening to a `_ready`
+ // stream, it has to listen to duration stream since duration
+ // is always added to the Stream sink after all icyMetadata has
+ // been loaded thus indicating buffering started
+ if (duration != Duration.zero && duration != _duration) {
+ // this line is for prev/next or already playing playlist
+ if (player.playing) await player.pause();
+ await player.play();
+ }
+ _duration = duration;
+ _callAllDurationListeners(duration);
+ // for avoiding unnecessary re-renders in other components that
+ // doesn't need duration
+ }
+ });
+
+ _processingStateStreamListener =
+ player.processingStateStream.listen((event) async {
+ try {
+ if (event == ProcessingState.completed && _currentTrack?.id != null) {
+ movePlaylistPositionBy(1);
+ }
+ } catch (e, stack) {
+ print("[PrecessingStateStreamListener] $e");
+ print(stack);
+ }
+ });
+
+ AudioSession.instance.then((session) async {
+ _audioSession = session;
+ await session.configure(const AudioSessionConfiguration.music());
+ _audioInterruptionEventListener = session.interruptionEventStream.listen(
+ (AudioInterruptionEvent event) {},
+ );
+ });
}
CurrentPlaylist? get currentPlaylist => _currentPlaylist;
Track? get currentTrack => _currentTrack;
+ bool get isPlaying => _isPlaying;
+
+ /// this duration field is almost static & changes occasionally
+ ///
+ /// If you want realtime duration with state-update/re-render
+ /// use custom state & the [addDurationChangeListener] function to do so
+ Duration? get duration => _duration;
+
+ _callAllDurationListeners(Duration? arg) {
+ for (var listener in _durationListeners) {
+ listener(arg);
+ }
+ }
+
+ void addDurationChangeListener(void Function(Duration? duration) listener) {
+ _durationListeners.add(listener);
+ }
+
+ void removeDurationChangeListener(
+ void Function(Duration? duration) listener) {
+ _durationListeners =
+ _durationListeners.where((p) => p != listener).toList();
+ }
set setCurrentTrack(Track track) {
_currentTrack = track;
@@ -54,7 +156,10 @@ class Playback extends ChangeNotifier {
notifyListeners();
}
- reset() {
+ void reset() {
+ _isPlaying = false;
+ _duration = null;
+ _callAllDurationListeners(null);
_currentPlaylist = null;
_currentTrack = null;
notifyListeners();
@@ -75,6 +180,82 @@ class Playback extends ChangeNotifier {
return false;
}
}
+
+ @override
+ dispose() {
+ _processingStateStreamListener?.cancel();
+ _durationStreamListener?.cancel();
+ _playingStreamListener?.cancel();
+ _audioInterruptionEventListener?.cancel();
+ _audioSession?.setActive(false);
+ super.dispose();
+ }
+
+ movePlaylistPositionBy(int pos) {
+ if (_currentTrack != null && _currentPlaylist != null) {
+ int index = _currentPlaylist!.trackIds.indexOf(_currentTrack!.id!) + pos;
+
+ var safeIndex = index > _currentPlaylist!.trackIds.length - 1
+ ? 0
+ : index < 0
+ ? _currentPlaylist!.trackIds.length
+ : index;
+ Track? track = _currentPlaylist!.tracks.asMap().containsKey(safeIndex)
+ ? _currentPlaylist!.tracks.elementAt(safeIndex)
+ : null;
+ if (track != null) {
+ _duration = null;
+ _callAllDurationListeners(null);
+ _currentTrack = track;
+ notifyListeners();
+ // starts to play the newly entered next/prev track
+ startPlaying();
+ }
+ }
+ }
+
+ Future startPlaying([Track? track]) async {
+ try {
+ // the track is already playing so no need to change that
+ if (track != null && track.id == _currentTrack?.id) return;
+ track ??= _currentTrack;
+ if (track != null && await _audioSession?.setActive(true) == true) {
+ Uri? parsedUri = Uri.tryParse(track.uri ?? "");
+ if (parsedUri != null && parsedUri.hasAbsolutePath) {
+ await player
+ .setAudioSource(
+ AudioSource.uri(parsedUri),
+ preload: true,
+ )
+ .then((value) async {
+ _currentTrack = track;
+ _duration = value;
+ _callAllDurationListeners(value);
+ notifyListeners();
+ });
+ }
+ final ytTrack = await toYoutubeTrack(youtube, track);
+ if (setTrackUriById(track.id!, ytTrack.uri!)) {
+ await player
+ .setAudioSource(
+ AudioSource.uri(Uri.parse(ytTrack.uri!)),
+ preload: true,
+ )
+ .then((value) {
+ _currentTrack = track;
+ notifyListeners();
+ });
+ }
+ }
+ } catch (e, stack) {
+ print("[Playback.startPlaying] $e");
+ print(stack);
+ }
+ }
}
-var x = Playback();
+final playbackProvider = ChangeNotifierProvider((ref) {
+ final player = ref.watch(audioPlayerProvider);
+ final youtube = ref.watch(youtubeProvider);
+ return Playback(player: player, youtube: youtube);
+});
diff --git a/lib/provider/SpotifyDI.dart b/lib/provider/SpotifyDI.dart
index fcec7a46..5d4f0597 100644
--- a/lib/provider/SpotifyDI.dart
+++ b/lib/provider/SpotifyDI.dart
@@ -1,10 +1,37 @@
-import 'package:flutter/cupertino.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotify/spotify.dart';
+import 'package:spotube/components/Home/Home.dart';
+import 'package:spotube/models/LocalStorageKeys.dart';
+import 'package:spotube/provider/Auth.dart';
-class SpotifyDI with ChangeNotifier {
- SpotifyApi _spotifyApi;
+var spotifyProvider = Provider((ref) {
+ Auth authState = ref.watch(authProvider);
- SpotifyDI(this._spotifyApi);
-
- SpotifyApi get spotifyApi => _spotifyApi;
-}
+ return SpotifyApi(
+ SpotifyApiCredentials(
+ authState.clientId,
+ authState.clientSecret,
+ accessToken: authState.accessToken,
+ refreshToken: authState.refreshToken,
+ expiration: authState.expiration,
+ scopes: spotifyScopes,
+ ),
+ onCredentialsRefreshed: (credentials) async {
+ SharedPreferences localStorage = await SharedPreferences.getInstance();
+ localStorage.setString(
+ LocalStorageKeys.refreshToken,
+ credentials.refreshToken!,
+ );
+ localStorage.setString(
+ LocalStorageKeys.accessToken,
+ credentials.accessToken!,
+ );
+ localStorage.setString(LocalStorageKeys.clientId, credentials.clientId!);
+ localStorage.setString(
+ LocalStorageKeys.clientSecret,
+ credentials.clientSecret!,
+ );
+ },
+ );
+});
diff --git a/lib/provider/ThemeProvider.dart b/lib/provider/ThemeProvider.dart
new file mode 100644
index 00000000..870c5aab
--- /dev/null
+++ b/lib/provider/ThemeProvider.dart
@@ -0,0 +1,6 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+var themeProvider = StateProvider((ref) {
+ return ThemeMode.system;
+});
diff --git a/lib/provider/UserPreferences.dart b/lib/provider/UserPreferences.dart
index 2fc6543c..7e881377 100644
--- a/lib/provider/UserPreferences.dart
+++ b/lib/provider/UserPreferences.dart
@@ -1,6 +1,7 @@
import 'dart:convert';
import 'package:flutter/cupertino.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/models/LocalStorageKeys.dart';
@@ -110,3 +111,5 @@ class UserPreferences extends ChangeNotifier {
notifyListeners();
}
}
+
+var userPreferencesProvider = ChangeNotifierProvider((_) => UserPreferences());
diff --git a/lib/provider/YouTube.dart b/lib/provider/YouTube.dart
new file mode 100644
index 00000000..d96f8c1f
--- /dev/null
+++ b/lib/provider/YouTube.dart
@@ -0,0 +1,4 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:youtube_explode_dart/youtube_explode_dart.dart';
+
+final youtubeProvider = Provider((ref) => YoutubeExplode());
diff --git a/pubspec.lock b/pubspec.lock
index f1d1989d..5a3d5be2 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -14,7 +14,7 @@ packages:
name: archive
url: "https://pub.dartlang.org"
source: hosted
- version: "3.1.8"
+ version: "3.2.1"
args:
dependency: transitive
description:
@@ -30,7 +30,7 @@ packages:
source: hosted
version: "2.8.2"
audio_session:
- dependency: transitive
+ dependency: "direct main"
description:
name: audio_session
url: "https://pub.dartlang.org"
@@ -121,7 +121,7 @@ packages:
source: hosted
version: "1.1.0"
collection:
- dependency: transitive
+ dependency: "direct main"
description:
name: collection
url: "https://pub.dartlang.org"
@@ -180,7 +180,7 @@ packages:
name: flutter_blurhash
url: "https://pub.dartlang.org"
source: hosted
- version: "0.6.0"
+ version: "0.6.4"
flutter_cache_manager:
dependency: transitive
description:
@@ -188,6 +188,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.3.0"
+ flutter_hooks:
+ dependency: "direct main"
+ description:
+ name: flutter_hooks
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.18.2+1"
flutter_lints:
dependency: "direct dev"
description:
@@ -195,6 +202,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.4"
+ flutter_riverpod:
+ dependency: "direct main"
+ description:
+ name: flutter_riverpod
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.3"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -211,7 +225,21 @@ packages:
name: freezed_annotation
url: "https://pub.dartlang.org"
source: hosted
- version: "0.14.3"
+ version: "1.1.0"
+ go_router:
+ dependency: "direct main"
+ description:
+ name: go_router
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "3.0.4"
+ hooks_riverpod:
+ dependency: "direct main"
+ description:
+ name: hooks_riverpod
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.3"
hotkey_manager:
dependency: "direct main"
description:
@@ -246,7 +274,7 @@ packages:
name: image
url: "https://pub.dartlang.org"
source: hosted
- version: "3.1.1"
+ version: "3.1.3"
infinite_scroll_pagination:
dependency: "direct main"
description:
@@ -254,13 +282,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
- injector:
- dependency: transitive
- description:
- name: injector
- url: "https://pub.dartlang.org"
- source: hosted
- version: "2.0.0"
js:
dependency: transitive
description:
@@ -281,7 +302,7 @@ packages:
name: just_audio
url: "https://pub.dartlang.org"
source: hosted
- version: "0.9.18"
+ version: "0.9.20"
just_audio_libwinmedia:
dependency: "direct main"
description:
@@ -295,14 +316,14 @@ packages:
name: just_audio_platform_interface
url: "https://pub.dartlang.org"
source: hosted
- version: "4.0.0"
+ version: "4.1.0"
just_audio_web:
dependency: transitive
description:
name: just_audio_web
url: "https://pub.dartlang.org"
source: hosted
- version: "0.4.2"
+ version: "0.4.7"
libwinmedia:
dependency: transitive
description:
@@ -317,6 +338,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
+ logging:
+ dependency: transitive
+ description:
+ name: logging
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "1.0.2"
matcher:
dependency: transitive
description:
@@ -344,14 +372,7 @@ packages:
name: msix
url: "https://pub.dartlang.org"
source: hosted
- version: "2.8.1"
- nested:
- dependency: transitive
- description:
- name: nested
- url: "https://pub.dartlang.org"
- source: hosted
- version: "1.0.0"
+ version: "2.8.18"
oauth2:
dependency: transitive
description:
@@ -373,6 +394,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.2"
+ palette_generator:
+ dependency: "direct main"
+ description:
+ name: palette_generator
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.3.3"
path:
dependency: "direct main"
description:
@@ -386,7 +414,7 @@ packages:
name: path_provider
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.8"
+ version: "2.0.9"
path_provider_android:
dependency: transitive
description:
@@ -407,28 +435,28 @@ packages:
name: path_provider_linux
url: "https://pub.dartlang.org"
source: hosted
- version: "2.1.4"
+ version: "2.1.5"
path_provider_macos:
dependency: transitive
description:
name: path_provider_macos
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.4"
+ version: "2.0.5"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.1"
+ version: "2.0.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.4"
+ version: "2.0.5"
pedantic:
dependency: transitive
description:
@@ -456,7 +484,7 @@ packages:
name: plugin_platform_interface
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.2"
+ version: "2.1.2"
process:
dependency: transitive
description:
@@ -464,13 +492,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.4"
- provider:
- dependency: "direct main"
+ riverpod:
+ dependency: transitive
description:
- name: provider
+ name: riverpod
url: "https://pub.dartlang.org"
source: hosted
- version: "6.0.1"
+ version: "1.0.3"
rxdart:
dependency: transitive
description:
@@ -484,35 +512,35 @@ packages:
name: shared_preferences
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.11"
+ version: "2.0.13"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.9"
+ version: "2.0.11"
shared_preferences_ios:
dependency: transitive
description:
name: shared_preferences_ios
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.8"
+ version: "2.1.0"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.3"
+ version: "2.1.0"
shared_preferences_macos:
dependency: transitive
description:
name: shared_preferences_macos
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.2"
+ version: "2.0.3"
shared_preferences_platform_interface:
dependency: transitive
description:
@@ -526,14 +554,14 @@ packages:
name: shared_preferences_web
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.2"
+ version: "2.0.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.3"
+ version: "2.1.0"
sky_engine:
dependency: transitive
description: flutter
@@ -566,14 +594,14 @@ packages:
name: sqflite
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.1"
+ version: "2.0.2"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
url: "https://pub.dartlang.org"
source: hosted
- version: "2.1.0"
+ version: "2.2.0"
stack_trace:
dependency: transitive
description:
@@ -581,6 +609,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.10.0"
+ state_notifier:
+ dependency: transitive
+ description:
+ name: state_notifier
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "0.7.2+1"
stream_channel:
dependency: transitive
description:
@@ -629,63 +664,63 @@ packages:
name: url_launcher
url: "https://pub.dartlang.org"
source: hosted
- version: "6.0.17"
+ version: "6.0.20"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
url: "https://pub.dartlang.org"
source: hosted
- version: "6.0.13"
+ version: "6.0.15"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
url: "https://pub.dartlang.org"
source: hosted
- version: "6.0.13"
+ version: "6.0.15"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.2"
+ version: "3.0.0"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.2"
+ version: "3.0.0"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.4"
+ version: "2.0.5"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.5"
+ version: "2.0.8"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
url: "https://pub.dartlang.org"
source: hosted
- version: "2.0.2"
+ version: "3.0.0"
uuid:
dependency: transitive
description:
name: uuid
url: "https://pub.dartlang.org"
source: hosted
- version: "3.0.5"
+ version: "3.0.6"
vector_math:
dependency: transitive
description:
@@ -699,14 +734,14 @@ packages:
name: win32
url: "https://pub.dartlang.org"
source: hosted
- version: "2.3.3"
+ version: "2.4.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
url: "https://pub.dartlang.org"
source: hosted
- version: "0.2.0"
+ version: "0.2.0+1"
xml:
dependency: transitive
description:
@@ -727,7 +762,7 @@ packages:
name: youtube_explode_dart
url: "https://pub.dartlang.org"
source: hosted
- version: "1.10.8"
+ version: "1.10.9+1"
sdks:
dart: ">=2.15.1 <3.0.0"
- flutter: ">=2.5.0"
+ flutter: ">=2.10.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index 23e11b4f..87dc4d5f 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -37,7 +37,6 @@ dependencies:
cached_network_image: ^3.2.0
html: ^0.15.0
http: ^0.13.4
- provider: ^6.0.1
shared_preferences: ^2.0.11
spotify: ^0.6.0
url_launcher: ^6.0.17
@@ -49,6 +48,13 @@ dependencies:
just_audio_libwinmedia: ^0.0.4
path: ^1.8.0
path_provider: ^2.0.8
+ collection: ^1.15.0
+ flutter_riverpod: ^1.0.3
+ flutter_hooks: ^0.18.2+1
+ hooks_riverpod: ^1.0.3
+ go_router: ^3.0.4
+ palette_generator: ^0.3.3
+ audio_session: ^0.1.6+1
dev_dependencies:
flutter_test: