diff --git a/README.md b/README.md index 34ca53ae..128b1d34 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ GitHub release - License + License Maintainer @@ -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 Download on Flathub ### 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: